diff --git a/README.md b/README.md index b0652a3..467069d 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ # Table of Contents - [Description](#description) +- [Quick Run](#quick-run) - [Context](#context) - [Impact](#impact) -- [Architecture](#architecture) +- [High Level Architecture](#high-level-architecture) - [Use Cases](#use-cases) -- [Features](#features) - [Configuration](#customizing-configuration) - [Run with Python](#run-with-python) - [Run with Docker](#run-with-docker) @@ -27,67 +27,383 @@ - [Configuring Security Hub](#configuring-security-hub) - [Configuring Context](#configuring-context) - [Inputs](#Inputs) -- [Output Modes](#output-modes) +- [Output](#outputs) - [Filters](#filters) -- [Updating Workflow Status](#updating-workflow-status) -- [Enriching Findings](#enriching-findings) -- [Findings Aggregation](#findings-aggregation) +- [Security Hub Actions](#security-hub-actions) +- [Contributing](#contributing) # Description -**MetaHub** is an open-source security tool for **impact-contextual vulnerability management**. It can automate the process of **contextualizing** security findings based on your environment and your needs: YOUR **context**, identifying **ownership**, and calculate an **impact scoring** based on it that you can use for defining prioritization and automation. You can use it with [AWS Security Hub](https://aws.amazon.com/security-hub) or any [ASFF](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-findings-format.html) security scanners (like [Prowler](https://github.com/prowler-cloud/prowler)). +**MetaHub** is an open-source security tool for **impact-contextual vulnerability management**. It can automate the process of **contextualizing** security findings based on your environment and your needs, YOUR **context**, identifying **ownership**, and calculate an **impact scoring** based on it that you can use for defining prioritization (where should you start?) and automations like remediations, alerts or tickets. The tool is for AWS environments and you can use it with [AWS Security Hub](https://aws.amazon.com/security-hub) or any [ASFF](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-findings-format.html) compatible scanners (like [Prowler](https://github.com/prowler-cloud/prowler)). -**MetaHub** describe your context by connecting to your affected resources in your affected accounts. It can describe information about your AWS account and organization, the affected resources tags, the affected CloudTrail events, your affected resource configurations, and all their associations: if you are contextualizing a security finding affecting an EC2 Instance, MetaHub will not only connect to that instance itself but also its IAM Roles; from there, it will connect to the IAM Policies associated with those roles. It will connect to the Security Groups and analyze all their rules, the VPC and the Subnets where the instance is running, the Volumes, the Auto Scaling Groups, and more. +**MetaHub** describes your [**context**](#context) by connecting to your affected resources in your affected accounts. It can describe information about your AWS account and organization, the affected resources tags, related CloudTrail events, your affected resource configurations **and all their associations**: if you are contextualizing a security finding affecting an EC2 Instance, **MetaHub** will not only connect to that instance itself but also its IAM Roles; from there, it will connect to the IAM Policies associated with those roles. It will connect to the Security Groups and analyze all their rules, the VPC and the Subnets where the instance is running, the Volumes, the Auto Scaling Groups, and more. You can apply [**filters**](#filters) to automate detecting other resources with the same properties and do in-depth investigations. -After fetching all the information from your context, **MetaHub** will evaluate certain important conditions for all your resources: `exposure`, `access`, `encryption`, `status`, `environment`, `application` and `owner`. Based on those calculations and in addition to the information from the security findings affecting the resource all together, MetaHub will generate a **Scoring** for each finding. +After fetching all the information from your **context**, **MetaHub** will evaluate the [**impact**](#impact) conditions for all your resources: [**exposure**](#exposure), [**access**](#access), [**encryption**](#encryption), [**status**](#status), [**environment**](#environment), [**application**](#application), and [**owner**](#owner) and based on those calculations and in addition to the information about the security [**findings**](#findings) affecting the resource all **together**, **MetaHub** will generate a **score** for each finding and affected resource. -Check the following dashboard generated by MetaHub. You have the affected resources, grouping all the security findings affecting them together and the original severity of the finding. After that, you have the **Impact Score** and all the criteria MetaHub evaluated to generate that score. All this information is filterable, sortable, groupable, downloadable, and customizable. +Check the following dashboard generated by **MetaHub**. You have the affected resources, grouping all the security findings affecting them together and the original severity of each finding. After that, you have the **score** and all the criteria **MetaHub** evaluated to generate that **score**. All this information is filterable, sortable, groupable, downloadable, and customizable. -You can rely on this **Impact Score** for prioritizing findings (where should you start?), directing attention to critical issues, and automating alerts and escalations. - -**MetaHub** can also filter, deduplicate, group, report, suppress, or update your security findings in automated workflows. It is designed for use as a CLI tool or within automated workflows, such as AWS Security Hub custom actions or AWS Lambda functions. - -The following is the JSON output for a an EC2 instance; see how MetaHub organizes all the information about its context together, under `associations`, `config`, `tags`, `account` `cloudtrail`, and `impact` +The following is the JSON [output](#outputs) for an EC2 instance; see how **MetaHub** organizes all the information about its **context** together, under [**associations**](#associations), [**config**](#config), [**tags**](#tags), [**account**](#account) and [**cloudtrail**](#cloudtrail), and finally the [**impact**](#impact) key with the **score** and all the criteria evaluated to generate that **score**.

Diagram

-# Context +**MetaHub** provides a range of ways to list, manage, and output your security findings for investigation, suppression, updating, and integration with other tools. It is designed for use as a [CLI](#run-with-python) tool or within automated workflows, such as [AWS Lambda functions](#run-with-lambda). It supports different **[outputs](#outputs)**, some of them [**programatic json**](#json), but also powerful [**html**](#html), [**xlsx**](#xlsx) and [**csv**](#csv) that you can [customize](#customize-html-csv-or-xlsx-outputs). + +If you use **AWS Security Hub**, MetaHub integrates smoothly and extends its functionalities. It can be used as a **[Security Hub Custom Action](#run-with-security-hub-custom-action)**, it supports **[AWS Security Hub filters](security-hub-filtering)**, you can manage the **[workflow status of your findings](#updating-workflow-status)**, and you can even **[enrich your findings directly in AWS Security Hub](#enriching-findings)**. + +**MetaHub** is designed to be used with AWS and supports **multi-account setups**. You can run the tool from any environment by assuming roles in your AWS Security Hub `master` and your `child/service` accounts. This allows you to fetch aggregated data from multiple accounts using your AWS Security Hub multi-account implementation while also fetching and enriching those findings with data from the accounts where your affected resources are running. Refer to [Configuring Security Hub](#configuring-security-hub) for more information. + +# Quick Run + +Read your security findings from AWS Security Hub with the default filters and executes the default context options: + +```bash +./metahub +``` -In **MetaHub**, context refers to information about the affected resources like their configuration, associations, logs, tags, account, and more. +Read your security findings from Prowler as an input file and executes the default context options: -MetaHub doesn't stop at the affected resource but analyzes any associated or attached resources. For instance, if there is a security finding on an EC2 instance, MetaHub will not only analyze the instance but also the security groups attached to it, including their rules. MetaHub will examine the IAM roles that the affected resource is using and the policies attached to those roles for any issues. It will analyze the EBS attached to the instance and determine if they are encrypted. It will also analyze the Auto Scaling Groups that the instance is associated with and how. MetaHub will also analyze the VPC, Subnets, and other resources associated with the instance. +```bash +python3 prowler.py aws -M json-asff -q +./metahub --inputs file-asff --input-asff /path/to/prowler-findings.json.asff +``` + +Read a specific (filtered by Id) security finding from AWS Security Hub and executes the default context options: -The **Context** module has the capability to retrieve information from the affected resources, affected accounts, and every associated resources. The context module has five main parts: `config` (which includes `associations` as well), `tags`, `cloudtrail`, and `account`. By default `config`, `tags` and `account` are enabled, but you can change this behavior using the option `--context` (for enabling all the context modules you can use `--context config tags cloudtrail account`). The output of each enabled key will be added under the affected resource. +```bash +./metahub --sh-filters Id=arn:aws:securityhub:us-east-1:123456789012:security-control/CloudFront.1/finding/8bd4d049-dcbc-445b-a5d1-595d8274b4c1 +``` -- [Config](#config) -- [Associations](#associations) -- [Tags](#tags) -- [CloudTrail](#cloudtrail) -- [Account](#account) +Read all the security findings affecting one resource which are ACTIVE (filtered by ResourceId and RecordState) from AWS Security Hub and executes the default context options: + +```bash +./metahub --sh-filters RecordState=ACTIVE ResourceId=arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-0b7d243ff90ebc03e +``` + +Read all the security findings affecting an AWS Account which are ACTIVE (filtered by AwsAccountId and RecordState) for resources with a tag `Environment` and the value `stg` and executes the context options `config` and `tags`: + +```bash +./metahub --sh-filters RecordState=ACTIVE AwsAccountId=123456789012 --mh-filters-tags Environment=stg --context config tags +``` + +# Context + +In **MetaHub**, **context** refers to information about the affected resources like their **configuration**, **associations**, **logs**, **tags** and **account**. + +MetaHub doesn't stop at the affected resource but also analyzes any associated or attached resources. For instance, if a security finding exists on a Security Group, **MetaHub** will analyze the Security Group and everything else associated with it, like the EC2 instances using it. For each associated resource, **MetaHub** will fetch its context. If the Security Group is attached to an EC2 Instance, **MetaHub** will analyze the instance and all its associations, like the IAM Roles and policies. From a single security finding on a Security Group, **MetaHub** will fetch the context of the Security Group, the EC2 Instance, the IAM Roles, and the IAM Policies. This is critical for understanding the impact of your security findings. + +The **context** module has five main parts: [**config**](#config) (which includes [**associations**](#associations)), [**tags**](#tags), [**cloudtrail**](#cloudtrail), and [**account**](#account). By default only **config**, **tags** and **account** are enabled, but you can change this behavior using the option `--context` (e.g. use `--context config tags cloudtrail account` for enabling all context keys, or `--context config` for enabling only the config and associations key.): ## Config -Under the `config` key, you can find anyting related to the configuration of the affected resource. For example, if the affected resource is an EC2 Instance, you will see keys like `private_ip`, `public_ip`, or `instance_profile`. +Under the `config` key, you can find important configuration from the affected resource. For example, if the affected resource is an S3 Bucket, you will find information about its bucket policy, its ACLs, its encryption configuration, and more. If the affected resource is an EC2 Instance, you will find information about its key, its public and private IP, its metadata and more. The configuration information that **MetaHub** fetches is defined by resource type. If you want to add more configuration information see [contributing](#contributing). -You can filter your findings based on Config outputs using the option: `--mh-filters-config {True/False}`. See [Config Filters](#config-filters). +You can filter your findings based on config outputs using the option: `--mh-filters-config {True/False}` (see [config filters](#config-filters)). + +
+ Example for an S3 bucket config key + +```json +"config": { + "resource_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Test", + "Effect": "Allow", + "Principal": { + "Service": "config.amazonaws.com" + }, + "Action": "s3:GetBucketAcl", + "Resource": "arn:aws:s3:::metahub-bucket", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "123456789012" + } + } + }, + ] + }, + "website_enabled": false, + "bucket_acl": [ + { + "Grantee": { + "DisplayName": "gabriel.soltz", + "ID": "1234564bd76c6c64080717b68eafaa588b41706daaf22d3d0705b398bd7cbd57", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "cannonical_user_id": "1234564bd76c6c64080717b68eafaa588b41706daaf22d3d0705b398bd7cbd57", + "public_access_block_enabled": { + "BlockPublicAcls": true, + "IgnorePublicAcls": true, + "BlockPublicPolicy": true, + "RestrictPublicBuckets": true + }, + "account_public_access_block_enabled": false, + "public": false, + "bucket_encryption": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + }, + "BucketKeyEnabled": false + } + ] +}, +``` + +
## Associations -Under the `associations` key, you will find all the associated resources of the affected resource. For example, if the affected resource is an EC2 Instance, you will find resources like: Security Groups, IAM Roles, Volumes, VPC, Subnets, Auto Scaling Groups, etc. Each time MetaHub finds an association, it will connect to the associated resource again and fetch its own context. +Under the `associations` key, you will find all the associated resources of the affected resource. For example, if the affected resource is an EC2 Instance, you will find resources like: Security Groups, IAM Roles, Volumes, VPC, Subnets, Auto Scaling Groups. If the affected resource is a IAM Role, you will find resources like IAM Policies, IAM Users, IAM Groups, and more. Each time MetaHub finds an association, it will connect to the associated resource again and fetch its own context. Associations are key to understanding the context and impact of your security findings. -Associations are key to understanding the context and impact of your security findings as their exposure. +You can filter your findings based on associations outputs using the option: `--mh-filters-config {True/False}` (see [config filters](#config-filters)). -You can filter your findings based on Associations outputs using the option: `--mh-filters-config {True/False}`. See [Config Filters](#config-filters). +
+ Example for an EC2 instance assocations key + +```json +"associations": { + "security_groups": { + "arn:aws:ec2:eu-west-1:123456789012:security-group/sg-020cc749a58678e05": { + "associations": { + "vpcs": { + "arn:aws:ec2:eu-west-1:123456789012:vpc/vpc-03cc56a1c2afb5760": { + "associations": { + "subnets": { + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-03d86f1ccd7729d85": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-0ccfb8dea658f49ec": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-05e85a7b0ec9e404c": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-0e177ea95bcc76256": {} + } + }, + "config": { + "cidr": "172.10.0.0/16", + "default": false, + "public": null + } + } + }, + "network_interfaces": { + "arn:aws:ec2:eu-west-1:123456789012:network-interface/eni-041a6e5bb59c336ee": {} + }, + "instances": { + "arn:aws:ec2:eu-west-1:123456789012:instance/i-018daeedcf06398c0": {} + } + }, + "config": { + "public_ips": [ + "100.100.100.100" + ], + "managed_services": [], + "its_referenced_by_a_security_group": false, + "security_group_rules": [ + { + "SecurityGroupRuleId": "sgr-08cdc9fdac8fd1a5b", + "GroupId": "sg-020cc749a58678e05", + "GroupOwnerId": "123456789012", + "IsEgress": true, + "IpProtocol": "-1", + "FromPort": -1, + "ToPort": -1, + "CidrIpv4": "0.0.0.0/0", + "Tags": [] + }, + { + "SecurityGroupRuleId": "sgr-0e6cd39169dc137ab", + "GroupId": "sg-020cc749a58678e05", + "GroupOwnerId": "123456789012", + "IsEgress": false, + "IpProtocol": "tcp", + "FromPort": 22, + "ToPort": 22, + "CidrIpv4": "0.0.0.0/0", + "Tags": [] + } + ], + "public": true, + "default": false, + "attached": true, + "resource_policy": null + } + }, + }, + "iam_roles": { + "arn:aws:iam::123456789012:role/eu-west-1-stg-backend-iam-role": { + "associations": { + "iam_policies": { + "arn:aws:iam::123456789012:policy/eu-west-1-stg-backend-iam-policy-cw": { + "associations": { + "iam_roles": { + "arn:aws:iam::123456789012:role/eu-west-1-stg-backend-iam-role": {} + }, + "iam_groups": {}, + "iam_users": {} + }, + "config": { + "name": "eu-west-1-stg-backend-iam-policy-cw", + "description": false, + "customer_managed": true, + "attached": true, + "public": null, + "resource_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ], + "Resource": [ + "arn:aws:logs:*:*:*" + ] + } + ] + } + } + }, + } + }, + "config": { + "iam_inline_policies": {}, + "instance_profile": "arn:aws:iam::123456789012:instance-profile/eu-west-1-stg-backend-iam-profile", + "trust_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "permissions_boundary": false, + "public": null, + "resource_policy": null + } + } + }, + "volumes": { + "arn:aws:ec2:eu-west-1:123456789012:volume/vol-0371a09e338f582da": { + "associations": { + "instances": { + "arn:aws:ec2:eu-west-1:123456789012:instance/i-018daeedcf06398c0": {} + } + }, + "config": { + "encrypted": true, + "attached": true, + "public": null, + "resource_policy": null + } + } + }, + "autoscaling_groups": { + "arn:aws:autoscaling:eu-west-1:123456789012:autoScalingGroup/stg-backend-20201205160228428400000002": { + "associations": { + "instances": { + "arn:aws:ec2:eu-west-1:123456789012:instance/i-018daeedcf06398c0": {} + }, + "launch_templates": { + "arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-06b73d2e77f10446f": {} + }, + "launch_configurations": {} + }, + "config": { + "name": "stg-backend-20201205160228428400000002", + "public": null, + "resource_policy": null + } + } + }, + "vpcs": { + "arn:aws:ec2:eu-west-1:123456789012:vpc/vpc-03cc56a1c2afb5760": { + "associations": { + "subnets": { + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-03d86f1ccd7729d85": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-0ccfb8dea658f49ec": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-05e85a7b0ec9e404c": {}, + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-0e177ea95bcc76256": {} + } + }, + "config": { + "cidr": "172.10.0.0/16", + "default": false, + "public": null + } + } + }, + "subnets": { + "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-03d86f1ccd7729d85": { + "associations": { + "route_tables": { + "arn:aws:ec2:eu-west-1:123456789012:route-table/rtb-0ebae6462f919943d": { + "associations": {}, + "config": { + "default": false, + "route_to_internet_gateway": [ + { + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": "igw-0790540d8d726f9d4", + "Origin": "CreateRoute", + "State": "active" + } + ], + "route_to_nat_gateway": [], + "route_to_transit_gateway": [], + "route_to_vpc_peering": [], + "public": null + } + } + }, + "network_interfaces": { + "arn:aws:ec2:eu-west-1:123456789012:network-interface/eni-0e8918fa31d2acd55": {}, + "arn:aws:ec2:eu-west-1:123456789012:network-interface/eni-0f6c936934fd9d6a6": {}, + "arn:aws:ec2:eu-west-1:123456789012:network-interface/eni-041a6e5bb59c336ee": {}, + "arn:aws:ec2:eu-west-1:123456789012:network-interface/eni-0e5f6cfdc7c286224": {} + }, + "instances": { + "arn:aws:ec2:eu-west-1:123456789012:instance/i-018daeedcf06398c0": {} + } + }, + "config": { + "cidr": "172.11.11.0/24", + "map_public_ip_on_launch_enabled": true, + "default": false, + "public": true, + "resource_policy": null, + "public_ips": [ + "100.100.100.100", + ], + "managed_services": [ + "ELB app/stg-alb-backend/3567d780bd062d75", + "Interface for NAT Gateway nat-0805d9808347bba69" + ], + "attached": true + } + } + }, +} +``` -## Tags +
-**MetaHub** relies on [AWS Resource Groups Tagging API](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/overview.html) to query the tags associated with your resources. +## Tags -Note that not all AWS resource type supports this API. You can check [supported services](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/supported-services.html). +Under the `tags` key, you will find all the tags associated with the affected resource. **MetaHub** relies on [AWS Resource Groups Tagging API](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/overview.html) to query the tags associated with your resources. Note that not all AWS resource type supports this API. You can check [supported services](https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/supported-services.html). Tags are a crucial part of understanding your context. Tagging strategies often include: @@ -98,37 +414,111 @@ Tags are a crucial part of understanding your context. Tagging strategies often If you follow a proper tagging strategy, you can filter and generate interesting outputs. For example, you could list all findings related to a specific team and provide that data directly to that team. -You can filter your findings based on Tags outputs using the option: `--mh-filters-tags TAG=VALUE`. See [Tags Filtering](#tags-filtering) +You can filter your findings based on tags outputs using the option: `--mh-filters-tags TAG=VALUE` (see [tags filters](#tags-filters)). + +
+ Example for an EC2 instance tag key + +```json +"tags": { + "aws:autoscaling:groupName": "stg-backend-20201205160228428400000002", + "environment": "stg", + "terraform": "true", + "aws:ec2launchtemplate:version": "8", + "aws:ec2launchtemplate:id": "lt-06b73d2e77f10446f", + "Name": "stg-backend" +} +``` + +
## CloudTrail -Under the key `cloudtrail`, you will find critical Cloudtrail events related to the affected resource, such as creating events. +Under the key `cloudtrail`, you will find critical Cloudtrail events related to the affected resource, such as creation events. The Cloudtrail events we look for are defined by resource type, and you can add, remove, or change them by editing the configuration file [resources.py](lib/config/resources.py). For example, for an affected resource of type Security Group, MetaHub will look for the following events: `CreateSecurityGroup` (Security Group Creation event) and `AuthorizeSecurityGroupIngress` (Security Group Rule Authorization event). + +
+ Example for an EC2 instance cloudtrail key + +```json +"cloudtrail": { + "RunInstances": { + "Username": "root", + "EventTime": "2023-11-15 06:10:07+01:00", + "EventId": "4f122d76-812d-4438-bc33-3585a9e863cf" + } +} +``` -The Cloudtrail events that we look for are defined by resource type, and you can add, remove or change them by editing the configuration file [resources.py](lib/config/resources.py). +
-For example for an affected resource of type Security Group, MetaHub will look for the following events: +
+ Example for a DynamoDB table cloudtrail key + +```json +"cloudtrail": { + "CreateTable": { + "Username": "gabriel.soltz", + "EventTime": "2023-12-05 14:34:25+01:00", + "EventId": "7110e3ae-09a3-44b9-929a-1775e0fbedcf" + } +} +``` -- `CreateSecurityGroup`: Security Group Creation event -- `AuthorizeSecurityGroupIngress`: Security Group Rule Authorization event. +
## Account -Under the key `account`, you will find information about the account where the affected resource is runnning, like if it's part of an AWS Organizations, information about their contacts, etc. +Under the key `account`, you will find information about the account where the affected resource is runnning, like if it's part of an AWS Organizations, information about their contacts, and more. -# Impact + + +
+ Example for account key + +```json +"account": { + "Alias": "metahub-demo", + "AlternateContact": { + "AlternateContactType": "SECURITY", + "EmailAddress": "gabriel@domain.com", + "Name": "Gabriel", + "PhoneNumber": "+1234567890", + "Title": "Security" + }, + "Organizations": { + "Arn": "arn:aws:organizations::123456789012:organization/o-12349772jb", + "Id": "o-12349772jb", + "MasterAccountId": "123456789012", + "MasterAccountEmail": "gabriel.soltz@domain.com", + "FeatureSet": "ALL", + "DelegatedAdministrators": {}, + "Details": { + "ParentId": "r-k123", + "ParentType": "ROOT", + "OU": "ROOT", + "Policies": { + "p-FullAWSAccess": { + "Name": "FullAWSAccess", + "Arn": "arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess", + "Type": "SERVICE_CONTROL_POLICY", + "Description": "Allows access to every operation", + "AwsManaged": true, + "Targets": [] + } + } + } + } +}, +``` -The impact module in MetaHub focuses on generating a score for each finding based on the context of the affected resource and all the security findings affecting them and their severities together. The impact score is a number between 0 and 100, where 100 is the highest impact. +
+ +# Impact -The formula for getting the impact score include the following criteria: + -- [Exposure](#exposure) -- [Access](#access) -- [Encryption](#encryption) -- [Status](#status) -- [Environment](#environment) -- [Application](#application) -- [Owner](#owner) -- [Findings](#findings) +The impact module in MetaHub focuses on understanding the 7 key properties of the affected resource: [**exposure**](#exposure), [**access**](#access), [**encryption**](#encryption), [**status**](#status), [**environment**](#environment), [**application**](#application), and [**owner**](#owner) and combining their values with the values of all the security [findings](#findings) affecting the same resource and their severities to generate a **score**. The impact score is a number between 0 and 100, where 100 is the highest impact. +


## Exposure @@ -143,6 +533,46 @@ The formula for getting the impact score include the following criteria: | 🟢 restricted | 0% | The resource is restricted. | | 🔵 unknown | - | The resource couldn't be checked | +
+ Example for an effectively-public resource + +```json +"exposure": { --> The exposure key + "effectively-public": { --> The exposure value, effectively-public + "entrypoint": "66.66.66.66", --> The entrypoint to the resource from the Internet (Ip, Domain, etc.) + "unrestricted_ingress_rules": [ --> The unrestricted ingress rules, if any + { + "SecurityGroupRuleId": "sgr-0553206714e321b87", + "GroupId": "sg-0a15a46e47f07d139", + "GroupOwnerId": "123456789012", + "IsEgress": false, + "IpProtocol": "tcp", + "FromPort": 22, + "ToPort": 22, + "CidrIpv4": "0.0.0.0/0", + "Tags": [] + } + ], + "unrestricted_egress_rules": [ --> The unrestricted egress rules, if any + { + "SecurityGroupRuleId": "sgr-007b509667896ebe3", + "GroupId": "sg-0a15a46e47f07d139", + "GroupOwnerId": "123456789012", + "IsEgress": true, + "IpProtocol": "-1", + "FromPort": -1, + "ToPort": -1, + "CidrIpv4": "0.0.0.0/0", + "Tags": [] + }, + ], + "resource_public_config": true --> The public configuration of the resource + } +} +``` + +
+ ## Access **Access** evaluates the resource policy layer. MetaHub checks every available policy including: IAM Managed policies, IAM Inline policies, Resource Policies, Bucket ACLS, and any association to other resources like IAM Roles which its policies are also analyzed . An unrestricted policy is not only an itsue itself of that policy, it afected any other resource which is using it. @@ -159,6 +589,30 @@ The formula for getting the impact score include the following criteria: | 🟢 restricted | 0% | The policy is restricted. | | 🔵 unknown | - | The policy couldn't be checked. | +
+ Example for an unrestricted-actions resource + +```json +"access": { --> The access key + "unrestricted-actions": { --> The access value, unrestricted-actions + "wildcard_actions": { --> The wildcard policies, if any + "arn:aws:iam::123456789012:policy/eu-west-1-stg-iam-policy-dynamodb-cache": [ + { + "Action": [ + "dynamodb:*" --> The wildcard action + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:dynamodb:eu-west-1:123456789012:table/table", + ] + } + ], + } +} +``` + +
+ ## Encryption **Encryption** evaluate the different encryption layers based on each resource type. For example, for some resources it evaluates if `at_rest` and `in_transit` encryption configuration are both enabled. @@ -169,6 +623,22 @@ The formula for getting the impact score include the following criteria: | 🟢 encrypted | 0% | The resource is fully encrypted including any of it's associations. | | 🔵 unknown | - | The resource encryption couldn't be checked. | +
+ Example for an unencrypted resource + +```json +"encryption": { --> The encryption key + "unencrypted": { --> The encryption value, unencrypted + "unencrypted_resources": [ --> the unencrypted resources associated with the affected resource, if any + "arn:aws:ec2:eu-west-1:012345678901:volume/vol-0ac713ec808a8d8bd" + ], + "resource_encryption_config": null --> The encryption configuration of the resource, if it has any + } +} +``` + +
+ ## Status **Status** evaluate the status of the affected resource in terms of attachment or functioning. For example, for an EC2 Instance we evaluate if the resource is running, stopped, or terminated, but for resources like EBS Volumes and Security Groups, we evaluate if those resources are attached to any other resource. @@ -183,6 +653,20 @@ The formula for getting the impact score include the following criteria: | 🟢 not-enabled | 0% | The resource supports enabled and it is not enabled. | | 🔵 unknown | - | The resource couldn't be checked for status. | +
+ Example for a running resource + +```json +"status": { --> The status key + "running": { --> The status value, running + "status": "running", --> The status configuration of the resource, if it has any + "attached": null --> The attachment configuration of the resource, if it has any + } +} +``` + +
+ ## Environment **Environment** evaluates the environment where the affected resource is running. By default, MetaHub defines 3 environments: `production`, `staging`, and `development`, but you can add, remove, or modify these environments based on your needs. MetaHub evaluates the environment based on the tags of the affected resource, the account id or the account alias. You can define your own environemnts definitions and strategy in the configuration file (See [Customizing Configuration](#customizing-configuration)). @@ -194,6 +678,21 @@ The formula for getting the impact score include the following criteria: | 🟢 development | 0% | It is a development resource. | | 🔵 unknown | - | The resource couldn't be checked for enviroment. | +
+ Example for a production resource matched by Tags + +```json +"environment": { --> The environment key + "production": { --> The environment value, production + "tags": { --> The parameters used for evaluating the environment, in this case tags + "Env": "prod" --> The tag and key found used for evaluating the environment + } + } +} +``` + +
+ ## Application **Application** evaluates the application that the affected resource is part of. MetaHub relies on the AWS [myApplications](https://docs.aws.amazon.com/awsconsolehelpdocs/latest/gsg/aws-myApplications.html) feature, which relies on the Tag `awsApplication`, but you can extend this functionality based on your context for example by defining other tags you use for defining applications or services (like `Service` or any other), or by relying on account id or alias. You can define your application definitions and strategy in the configuration file (See [Customizing Configuration](#customizing-configuration)). @@ -202,6 +701,21 @@ The formula for getting the impact score include the following criteria: | --------------------- | :-------: | ------------------------------------------------- | | 🔵 unknown | - | The resource couldn't be checked for application. | +
+ Example for a resource matched by myApplication Tag + +```json +"application": { --> The application key + "payments-app": { --> The application value, payments-app + "tags": { --> The parameters used for evaluating the environment, in this case the tag awsApplication + "awsApplication": "arn:aws:resource-groups:eu-west-1:123456789012:group/app1/0c8vpbjkzeeffsz2cqgxpae7b2" --> The tag and key found used for evaluating the environment + } + } +} +``` + +
+ ## Owner **Owner** focuses on ownership detection. It can determine the owner of the affected resource in various ways. This information can be used to automatically assign a security finding to the correct owner, escalate it, or make decisions based on this information. An automated way to determine the owner of a resource is critical for security teams. It allows them to focus on the most critical issues and assign them as fast as possible to the right people in automated workflows. You can define your owner definitions and strategy in the configuration file (See [Customizing Configuration](#customizing-configuration)). @@ -210,6 +724,22 @@ The formula for getting the impact score include the following criteria: | --------------------- | :-------: | ------------------------------------------- | | 🔵 unknown | - | The resource couldn't be checked for owner. | +
+ Example for a resource matched by Account Id + +```json +"owner": { --> The owner key + "payments-team": { --> The owner value, payments-app + "account": { --> The parameters used for evaluating the environment, in this case the account + "account_ids": ["123456789012"], --> The account ids found used for evaluating the environment + "account_aliases": [], + }, + } +} +``` + +
+ ## Findings As part of the impact score calculation, we also evaluate the total ammount of security findings and their severities affecting the resource. We use the following formula to calculate this metric: @@ -242,30 +772,6 @@ Some use cases for MetaHub include: - Created enriched HTML reports for your findings that you can filter, sort, group, and download - Create Security Hub Insights based on MetaHub context -# Features - -**MetaHub** provides a range of ways to list and manage security findings for investigation, suppression, updating, and integration with other tools or alerting systems. To avoid _Shadowing_ and _Duplication_, MetaHub organizes related findings together when they pertain to the same resource. For more information, refer to [Findings Aggregation](#findings-aggregation) - -**MetaHub** queries the affected resources directly in the affected account to provide additional **context** using the following options: - -- **[Config](#Config)**: Fetches the most important configuration values from the affected resource. -- **[Associations](#Associations)**: Fetches all the associations of the affected resource, such as IAM roles, security groups, and more. -- **[Tags](#Tags)**: Queries tagging from affected resources -- **[CloudTrail](#CloudTrail)**: Queries CloudTrail in the affected account to identify who created the resource and when, as well as any other related critical events -- **[Account](#Account)**: Fetches extra information from the account where the affected resource is running, such as the account name, security contacts, and other information. - -**MetaHub** supports filters on top of these context\* outputs to automate the detection of other resources with the same issues. You can filter security findings affecting resources tagged in a certain way (e.g., `Environment=production`) and combine this with filters based on Config or Associations, like, for example, if the resource is public, if it is encrypted, only if they are part of a VPC, if they are using a specific IAM role, and more. For more information, refer to **[Config filters](#config-filters)** and **[Tags filters](#tags-filters)** for more information. - -But that's not all. If you are using **MetaHub** with Security Hub, you can even combine the previous filters with the Security Hub native filters (**[AWS Security Hub filtering](security-hub-filtering)**). You can filter the same way you would with the AWS CLI utility using the option `--sh-filters`, but in addition, you can save and re-use your filters as YAML files using the option `--sh-template`. - -If you prefer, With **MetaHub**, you can back **[enrich your findings directly in AWS Security Hub](#enriching-findings)** using the option `--enrich-findings`. This action will update your AWS Security Hub findings using the field `UserDefinedFields`. You can then create filters or [Insights](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-insights.html) directly in AWS Security Hub and take advantage of the contextualization added by MetaHub. - -When investigating findings, you may need to update security findings altogether. **MetaHub** also allows you to execute **[bulk updates](#updating-workflow-status)** to AWS Security Hub findings, such as changing Workflow Status using the option `--update-findings`. As an example, you identified that you have hundreds of security findings about public resources. Still, based on the MetaHub context, you know those resources are not effectively public as they are protected by routing and firewalls. You can update all the findings for the output of your MetaHub query with one command. When updating findings using MetaHub, you also update the field `Note` of your finding with a custom text for future reference. - -**MetaHub** supports different **[Output Modes](#output-modes)**, some of them **json based** like **json-inventory**, **json-statistics**, **json-short**, **json-full**, but also powerfull **html**, **xlsx** and **csv**. These outputs are customizable; you can choose which columns to show. For example, you may need a report about your affected resources, adding the tag Owner, Service, and Environment and nothing else. Check the configuration file and define the columns you need. - -**MetaHub** supports **multi-account setups**. You can run the tool from any environment by assuming roles in your AWS Security Hub `master` account and your `child/service` accounts where your resources live. This allows you to fetch aggregated data from multiple accounts using your AWS Security Hub multi-account implementation while also fetching and enriching those findings with data from the accounts where your affected resources live based on your needs. Refer to [Configuring Security Hub](#configuring-security-hub) for more information. - # Customizing Configuration **MetaHub** uses configuration files that let you customize some checks behaviors, default filters, and more. The configuration files are located in [lib/config/](lib/config). @@ -410,7 +916,7 @@ The Security Hub custom action is deployed as part of the Terraform code. See [D # AWS Authentication -- Ensure you have AWS credentials set up on your local machine (or from where you will run MetaHub). +Ensure you have AWS credentials set up on your local machine (or from where you will run MetaHub). For example, you can use `aws configure` option. @@ -495,13 +1001,9 @@ You also can combine AWS Security Hub findings with input ASFF files specifying When using a file as input, you can't use the option `--sh-filters` for filter findings, as this option relies on AWS API for filtering. You can't use the options `--update-findings` or `--enrich-findings` as those findings are not in the AWS Security Hub. If you are reading from both sources at the same time, only the findings from AWS Security Hub will be updated. -# Output Modes +# Outputs -**MetaHub** can generate different programmatic and visual outputs. By default, all output modes are enabled: `json-short`, `json-full`, `json-statistics`, `json-inventory`, `html`, `csv`, and `xlsx`. - -The outputs will be saved in the `outputs/` folder with the execution date. - -If you want only to generate a specific output mode, you can use the option `--output-modes` with the desired output mode. +**MetaHub** can generate different programmatic and visual outputs. By default, all output modes are enabled: `json-short`, `json-full`, `json-statistics`, `json-inventory`, `html`, `csv`, and `xlsx`. If you want only to generate a specific output mode, you can use the option `--output-modes` with the desired output mode. The outputs will be saved in the `outputs/` folder with the execution date. For example, if you only want to generate the output `json-short`, you can use: @@ -515,6 +1017,8 @@ If you want to generate `json-short`, `json-full` and `html` outputs, you can us ./metahub.py --output-modes json-short json-full html ``` +**MetaHub** organizes the security findings affecting the same resource all together under the `findings` key for in an attempt to avoid Shadowing (when two checks refer to the same issue, but one in a more generic way than the other one) and Duplication (when you use more than one scanner and get the same problem from more than one.). You can see this behaviour clear in the outputs `json-short`, `json-full` and `html`. + - [JSON](#json) - [HTML](#html) - [CSV](#csv) @@ -522,96 +1026,186 @@ If you want to generate `json-short`, `json-full` and `html` outputs, you can us ## JSON -### JSON-Short - -Show all findings titles together under each affected resource and the `AwsAccountId`, `Region`, and `ResourceType`: - -``` -"arn:aws:sagemaker:us-east-1:ofuscated:notebook-instance/obfuscated": { - "findings": [ - "SageMaker.2 SageMaker notebook instances should be launched in a custom VPC", - "SageMaker.3 Users should not have root access to SageMaker notebook instances", - "SageMaker.1 Amazon SageMaker notebook instances should not have direct internet access" - ], - "AwsAccountId": "obfuscated", - "Region": "us-east-1", - "ResourceType": "AwsSageMakerNotebookInstance", - "config: {}, - "associations: {}, - "tags: {}, - "cloudtrail: {}, - "account: {} - "impact: {} -}, -``` +> :info: For exploring a JSON output interactively, you can use the tool [fx](https://github.com/antonmedv/fx). ### JSON-Full -Show all findings with all data. Findings are organized by ResourceId (ARN). For each finding, you will also get: `SeverityLabel,` `Workflow,` `RecordState,` `Compliance,` `Id`, and `ProductArn`: +Shows the affected resource using it's ARN as the key and the findings affecting it as a list under the key `findings`. In adittion to the findings, you will also get the following keys: `ResourceType`, `Region`, `AwsAccountId`, `associations`, `config`, `tags`, `account`, `cloudtrail`, and `impact`. -``` -"arn:aws:sagemaker:eu-west-1:ofuscated:notebook-instance/obfuscated": { - "findings": [ +```json +"arn:aws:ec2:eu-west-1:1234567890:instance/i-0a40b2be25dbac0ac": { --> The affected resource ARN + "findings": [ --> The findings affecting the resource { - "SageMaker.3 Users should not have root access to SageMaker notebook instances": { - "SeverityLabel": "HIGH", - "Workflow": { - "Status": "NEW" + "EC2 instances should use Instance Metadata Service Version 2 (IMDSv2)": { --> The finding title + "SeverityLabel": "HIGH", --> The finding severity label + "Workflow":{ --> The finding workflow + "Status": "NEW", }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" + "RecordState": "ACTIVE", --> The finding record state + "Compliance":{ --> The finding compliance + "Status": "FAILED", }, - "Id": "arn:aws:security hub:eu-west-1:ofuscated:subscription/aws-foundational-security-best-practices/v/1.0.0/SageMaker.3/finding/12345-0193-4a97-9ad7-bc7c1730eec6", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" + "Id": "arn:aws:securityhub:eu-west-1:123456789012:security-control/EC2.8/finding/a1d4f19f-453e-4c3c-b486-8443c73e84f1", + "ProductArn": "arn:aws:securityhub:eu-west-1::product/aws/securityhub", } }, - { - "SageMaker.2 SageMaker notebook instances should be launched in a custom VPC": { - "SeverityLabel": "HIGH", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:ofuscated:subscription/aws-foundational-security-best-practices/v/1.0.0/SageMaker.2/finding/12345-e8e1-4915-9881-965104b0aabf", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" - } + {"EC2 instances should be managed by AWS Systems Manager": {...}}, --> Another finding title + {"EC2 instances should not have a public IPv4 address": {...}} --> Another finding title + ], + "ResourceType": "AwsEc2Instance", --> The affected resource type + "Region": "eu-west-1", --> The affected resource region + "AwsAccountId": "1234567890", --> The affected resource account id + "associations": { --> The associations of the affected resource + "security_groups": {}, --> The security groups associated with the affected resource + "iam_roles": {}, --> The IAM roles associated with the affected resource + "volumes": {}, --> The volumes associated with the affected resource + "autoscaling_groups": {}, --> The autoscaling groups associated with the affected resource + "vpcs": {}, --> The VPCs associated with the affected resource + "subnets": {}, --> The subnets associated with the affected resource + }, + "config": { --> The configuration of the affected resource (based on it's type) + "public_ip": "200.200.200.200", --> The public IP of the affected resource, if any + "private_ip": "10.10.10.10", --> The private IP of the affected resource, if any + "key": "ssh-key", --> The key used for the affected resource, if any + "metadata_options": { --> The metadata options of the affected resource, if any + "State": "applied", + "HttpTokens": "required", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled", + "HttpProtocolIpv6": "disabled", + "InstanceMetadataTags": "disabled" }, - { - "SageMaker.1 Amazon SageMaker notebook instances should not have direct internet access": { - "SeverityLabel": "HIGH", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:ofuscated:subscription/aws-foundational-security-best-practices/v/1.0.0/SageMaker.1/finding/12345-3a21-4016-a8e5-f5173b44e90a", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" + }, + "tags": { --> The tags of the affected resource + "Name": "test", --> The tag key and value + "Env": "prod", + "awsApplication": "arn:aws:resource-groups:eu-west-1:1234567890:group/app1/0c8vpbjkzeeffsz2cqgxpae7b2" + }, + "account": { --> The account of the affected resource + "Alias": "prod", --> The account alias + "AlternateContact": {}, --> The alternate contact of the account, if any + "Organizations": { --> The organization of the account, if any + "Id": "o-1234567890", + "Arn": "arn:aws:organizations::1234567890:organization/o-1234567890/o-1234567890", + "MasterAccountId": "1234567890", + "MasterAccountArn": "arn:aws:organizations::1234567890:account/o-1234567890/1234567890", + "MasterAccountEmail": "", + "Details": { + "ParentId": "p-k1234567890", + "ParentType": "ROOT", + "OU": "ROOT", + "Policies": { --> The policies of the account, if any + "p-FullAWSAccess": {...} --> The policy name and policy + } } + }, + }, + "cloudtrail": { --> The CloudTrail events affecting the affected resource + "RunInstances": { --> The CloudTrail event name + "Username": "test", --> The username of the event, if any + "EventTime": "2021-01-01T00:00:00Z", + "EventId": "12345678-1234-1234-1234-123456789012", } + }, + "impact": { --> The impact of the affected resource + "exposure": {...}, --> The exposure impact + "access": {...}, --> The access impact + "encryption": {...}, --> The encryption impact + "status": {...}, --> The status impact + "environment": {...}, --> The environment impact + "application": {...}, --> The application impact + "owner": {...}, --> The owner impact + "findings": {...}, --> The findings impact + "score": {...} --> The total impact score + } +} +``` + +### JSON-Short + +Shows the affected resource using it's ARN as the key and the findings affecting it as a list under the key `findings`. In adittion to the findings, you will also get the following keys: `ResourceType`, `Region`, `AwsAccountId`, `associations`, `config`, `tags`, `account`, `cloudtrail`, and `impact`. + +```json +"arn:aws:ec2:eu-west-1:1234567890:instance/i-0a40b2be25dbac0ac": { --> The affected resource ARN + "findings": [ --> The findings affecting the resource + "EC2 instances should use Instance Metadata Service Version 2 (IMDSv2)", --> The finding title + "EC2 instances should be managed by AWS Systems Manager", --> The finding title + "EC2 instances should not have a public IPv4 address" --> The finding title ], - "AwsAccountId": "obfuscated", - "Region": "eu-west-1", - "ResourceType": "AwsSageMakerNotebookInstance", - "config: {}, - "associations: {}, - "tags: {}, - "cloudtrail: {}, - "account: {} - "impact: {} -}, + "ResourceType": "AwsEc2Instance", --> The affected resource type + "Region": "eu-west-1", --> The affected resource region + "AwsAccountId": "1234567890", --> The affected resource account id + "associations": { --> The associations of the affected resource + "security_groups": {}, --> The security groups associated with the affected resource + "iam_roles": {}, --> The IAM roles associated with the affected resource + "volumes": {}, --> The volumes associated with the affected resource + "autoscaling_groups": {}, --> The autoscaling groups associated with the affected resource + "vpcs": {}, --> The VPCs associated with the affected resource + "subnets": {}, --> The subnets associated with the affected resource + }, + "config": { --> The configuration of the affected resource (based on it's type) + "public_ip": "200.200.200.200", --> The public IP of the affected resource, if any + "private_ip": "10.10.10.10", --> The private IP of the affected resource, if any + "key": "ssh-key", --> The key used for the affected resource, if any + "metadata_options": { --> The metadata options of the affected resource, if any + "State": "applied", + "HttpTokens": "required", + "HttpPutResponseHopLimit": 1, + "HttpEndpoint": "enabled", + "HttpProtocolIpv6": "disabled", + "InstanceMetadataTags": "disabled" + }, + }, + "tags": { --> The tags of the affected resource + "Name": "test", --> The tag key and value + "Env": "prod", + "awsApplication": "arn:aws:resource-groups:eu-west-1:1234567890:group/app1/0c8vpbjkzeeffsz2cqgxpae7b2" + }, + "account": { --> The account of the affected resource + "Alias": "prod", --> The account alias + "AlternateContact": {}, --> The alternate contact of the account, if any + "Organizations": { --> The organization of the account, if any + "Id": "o-1234567890", + "Arn": "arn:aws:organizations::1234567890:organization/o-1234567890/o-1234567890", + "MasterAccountId": "1234567890", + "MasterAccountArn": "arn:aws:organizations::1234567890:account/o-1234567890/1234567890", + "MasterAccountEmail": "", + "Details": { + "ParentId": "p-k1234567890", + "ParentType": "ROOT", + "OU": "ROOT", + "Policies": { --> The policies of the account, if any + "p-FullAWSAccess": {...} --> The policy name and policy + } + } + }, + }, + "cloudtrail": { --> The CloudTrail events affecting the affected resource + "RunInstances": { --> The CloudTrail event name + "Username": "test", --> The username of the event, if any + "EventTime": "2021-01-01T00:00:00Z", + "EventId": "12345678-1234-1234-1234-123456789012", + } + }, + "impact": { --> The impact of the affected resource + "exposure": {...}, --> The exposure impact + "access": {...}, --> The access impact + "encryption": {...}, --> The encryption impact + "status": {...}, --> The status impact + "environment": {...}, --> The environment impact + "application": {...}, --> The application impact + "owner": {...}, --> The owner impact + "findings": {...}, --> The findings impact + "score": {...} --> The total impact score + } +} ``` ### JSON-Inventory Show a list of all resources with their ARN. -``` +```json [ "arn:aws:sagemaker:us-east-1:ofuscated:notebook-instance/obfuscated", "arn:aws:sagemaker:eu-west-1:ofuscated:notebook-instance/obfuscated" @@ -622,12 +1216,12 @@ Show a list of all resources with their ARN. Show statistics for each field/value. In the output, you will see each field/value and the number of occurrences; for example, the following output shows statistics for six findings. -``` +```json { "Title": { "SageMaker.1 Amazon SageMaker notebook instances should not have direct internet access": 2, "SageMaker.2 SageMaker notebook instances should be launched in a custom VPC": 2, - "SageMaker.3 Users should not have root access to SageMaker notebook instances": 2, + "SageMaker.3 Users should not have root access to SageMaker notebook instances": 2 }, "SeverityLabel": { "HIGH": 6 @@ -844,7 +1438,9 @@ Examples: ./metahub --sh-filters RecordState=ACTIVE WorkflowStatus=NEW ResourceType=AwsEc2SecurityGroup --mh-filters-tags Environment=Production ``` -# Updating Workflow Status +# Security Hub Actions + +## Updating Workflow Status You can use **MetaHub** to update your AWS Security Hub Findings workflow status (`NOTIFIED,` `NEW,` `RESOLVED,` `SUPPRESSED`) with a single command. You will use the `--update-findings` option to update all the findings from your MetaHub query. This means you can update one, ten, or thousands of findings using only one command. AWS Security Hub API is limited to 100 findings per update. Metahub will split your results into 100 items chucks to avoid this limitation and update your findings beside the amount. @@ -866,7 +1462,7 @@ Running the following update command will update those six findings' workflow st The `--update-findings` will ask you for confirmation before updating your findings. You can skip this confirmation by using the option `--no-actions-confirmation`. -# Enriching Findings +## Enriching Findings You can use **MetaHub** to enrich back your AWS Security Hub Findings with Context outputs using the option `--enrich-findings`. Enriching your findings means updating them directly in AWS Security Hub. **MetaHub** uses the `UserDefinedFields` field for this. @@ -884,124 +1480,6 @@ For example, you want to enrich all AWS Security Hub findings with `WorkflowStat The `--enrich-findings` will ask you for confirmation before enriching your findings. You can skip this confirmation by using the option `--no-actions-confirmation`. -# Findings Aggregation - -Working with Security Findings sometimes introduces the problem of Shadowing and Duplication. - -Shadowing is when two checks refer to the same issue, but one in a more generic way than the other one. - -Duplication is when you use more than one scanner and get the same problem from more than one. - -Think of a Security Group with port 3389/TCP open to 0.0.0.0/0. Let's use Security Hub findings as an example. - -If you are using one of the default Security Standards like `AWS-Foundational-Security-Best-Practices,` you will get two findings for the same issue: - -- `EC2.18 Security groups should only allow unrestricted incoming traffic for authorized ports` -- `EC2.19 Security groups should not allow unrestricted access to ports with high risk` - -If you are also using the standard CIS AWS Foundations Benchmark, you will also get an extra finding: - -- `4.2 Ensure no security groups allow ingress from 0.0.0.0/0 to port 3389` - -Now, imagine that SG is not in use. In that case, Security Hub will show an additional fourth finding for your resource! - -- `EC2.22 Unused EC2 security groups should be removed` - -So now you have in your dashboard four findings for one resource! - -Suppose you are working with multi-account setups and many resources. In that case, this could result in many findings that refer to the same thing without adding any extra value to your analysis. - -**MetaHub** aggregates security findings under the affected resource. - -This is how MetaHub shows the previous example with output-mode json-short: - -```sh -"arn:aws:ec2:eu-west-1:01234567890:security-group/sg-01234567890": { - "findings": [ - "EC2.19 Security groups should not allow unrestricted access to ports with high risk", - "EC2.18 Security groups should only allow unrestricted incoming traffic for authorized ports", - "4.2 Ensure no security groups allow ingress from 0.0.0.0/0 to port 3389", - "EC2.22 Unused EC2 security groups should be removed" - ], - "AwsAccountId": "01234567890", - "Region": "eu-west-1", - "ResourceType": "AwsEc2SecurityGroup" -} -``` - -This is how MetaHub shows the previous example with output-mode json-full: - -```sh -"arn:aws:ec2:eu-west-1:01234567890:security-group/sg-01234567890": { - "findings": [ - { - "EC2.19 Security groups should not allow unrestricted access to ports with high risk": { - "SeverityLabel": "CRITICAL", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:01234567890:subscription/aws-foundational-security-best-practices/v/1.0.0/EC2.22/finding/01234567890-1234-1234-1234-01234567890", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" - } - }, - { - "EC2.18 Security groups should only allow unrestricted incoming traffic for authorized ports": { - "SeverityLabel": "HIGH", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:01234567890:subscription/aws-foundational-security-best-practices/v/1.0.0/EC2.22/finding/01234567890-1234-1234-1234-01234567890", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" - } - }, - { - "4.2 Ensure no security groups allow ingress from 0.0.0.0/0 to port 3389": { - "SeverityLabel": "HIGH", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:01234567890:subscription/aws-foundational-security-best-practices/v/1.0.0/EC2.22/finding/01234567890-1234-1234-1234-01234567890", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" - } - }, - { - "EC2.22 Unused EC2 security groups should be removed": { - "SeverityLabel": "MEDIUM", - "Workflow": { - "Status": "NEW" - }, - "RecordState": "ACTIVE", - "Compliance": { - "Status": "FAILED" - }, - "Id": "arn:aws:security hub:eu-west-1:01234567890:subscription/aws-foundational-security-best-practices/v/1.0.0/EC2.22/finding/01234567890-1234-1234-1234-01234567890", - "ProductArn": "arn:aws:security hub:eu-west-1::product/aws/security hub" - } - } - ], - "AwsAccountId": "01234567890", - "AwsAccountAlias": "obfuscated", - "Region": "eu-west-1", - "ResourceType": "AwsEc2SecurityGroup" -} -``` - -Your findings are combined under the ARN of the resource affected, ending in only one result or one non-compliant resource. - -You can now work in MetaHub with all these four findings together as if they were only one. For example, you can update these four Workflow Status findings using only one command: See [Updating Workflow Status](#updating-workflow-status) - # Contributing You can follow this guide if you want to contribute to the Context module [guide](docs/context.md). diff --git a/docs/context.md b/docs/context.md index e0f3fc3..8478c7b 100644 --- a/docs/context.md +++ b/docs/context.md @@ -1,71 +1,108 @@ -# Context Development +# Context Module -The ResourceType defines the MetaChecks to be executed. When there is an AWS Security Hub finding for an S3 Bucket (ResourceType: AwsS3Bucket), all the MetaChecks available for that resource will execute and be added as extra information under the ARNs resource. +The context module has 4 main components: config, tags, account and cloudtrail. -- [How it works](#how-it-works) -- [Adding a new AWS ResourceType for MetaChecks](#adding-a-new-aws-resourcetype-for-metachecks) -- [Creating MetaChecks](#creating-metachecks) +The config component is responsible for fetching the configuration and the associated resources. This configuration and associated resources are defined by resource type, under `lib/context/resources` you will find a file for each resource type. -## How it works +## Adding a new AWS ResourceType -Context works this way: +If you want to add context for a ResourceType that has not yet been defined in MetaHub, you will first need to add the ResourceType as a Class: -1. Connect to the account where the resource lives assuming the provided role (`--mh-assume-role`) -2. Describe the resource using describe functions -3. Executes MetaChecks on top of the described resource -4. Add the MetaChecks output to your affected resources -5. Apply filters if provided (`--mh-filters-checks`) -6. Output the list of affected resources with MetaChecks outputs that matchs your filters +1. Create a new file under `lib/context/resources` with the ResourceType as name, for example `AwsS3Bucket.py` -## Adding a new AWS ResourceType for MetaChecks +2. Start with this template as a base. We are using a base Class MetaChecksBase for every ResourceType. -If you want to add MetaChecks for a ResourceType that has not yet been defined in MetaHub, you will first need to add the ResourceType as a Class: +```python +"""ResourceType: Name of the ResourceType""" -1. Create a new file under `metachecks/checks` with the ResourceType as name, for example `AwsS3Bucket.py` - -2. Start with this template as a base. We are using a base Class (MetaChecksBase) to provide the filtering functionality. - -``` -'''MetaCheck: ''' +from botocore.exceptions import ClientError from lib.AwsHelpers import get_boto3_client -from lib.metachecks.checks.Base import MetaChecksBase -from lib.context.resources.MetaChecksHelpers import IamHelper +from lib.context.resources.Base import ContextBase -class Metacheck(MetaChecksBase): +class Metacheck(ContextBase): def __init__( self, logger, finding, - metachecks, - mh_filters_checks, + mh_filters_config, sess, drilled=False, ): self.logger = logger - if metachecks: - self.region = finding["Region"] - self.account = finding["AwsAccountId"] - self.partition = finding["Resources"][0]["Id"].split(":")[1] - self.finding = finding - self.sess = sess - self.resource_arn = finding["Resources"][0]["Id"] - self.resource_id = finding["Resources"][0]["Id"].split("/")[1] - self.mh_filters_checks = mh_filters_checks - self.client = get_boto3_client(self.logger, "ec2", self.region, self.sess) + self.sess = sess + self.mh_filters_config = mh_filters_config + self.parse_finding(finding, drilled) + self.client = get_boto3_client(self.logger, "SERVICE", self.region, self.sess) --> YOUR BOTO3 CLIENT + # Describe Resource + self.RESOURCE_TYPE = self.describe_RESOURCE_TYPE() --> You will need a describe function for your resource type + if not self.RESOURCE_TYPE: --> Handling if the resource does not exist + return False + # Drilled Associations + self.iam_roles = self._describe_instance_iam_roles() --> Add your associations, needs to be a dictionary {"arn": {}} + + # Parse --> How to parse the resource id from the ARN + def parse_finding(self, finding, drilled): + self.finding = finding + self.region = finding["Region"] + self.account = finding["AwsAccountId"] + self.partition = finding["Resources"][0]["Id"].split(":")[1] + self.resource_type = finding["Resources"][0]["Type"] + self.resource_id = ( --> When the resource is drilled, it get's the arn as drilled + finding["Resources"][0]["Id"].split("/")[-1] + if not drilled + else drilled.split("/")[-1] + ) + self.resource_arn = finding["Resources"][0]["Id"] if not drilled else drilled --> When the resource is drilled, it get's the arn as drilled + + # Describe Functions + + def describe_RESOURCE_TYPE(self): --> Describe function for your resource type + try: + response = self.client.describe_instances( + InstanceIds=[ + self.resource_id, + ], + Filters=[ + { + "Name": "instance-state-name", + "Values": [ + "pending", + "running", + "shutting-down", + "stopping", + "stopped", + ], + } + ], ) + if response["Reservations"]: + return response["Reservations"][0]["Instances"][0] + except ClientError as err: + if not err.response["Error"]["Code"] == "InvalidInstanceID.NotFound": + self.logger.error( + "Failed to describe_instance: {}, {}".format(self.resource_id, err) + ) + return False + + # Context Config + + + def associations(self): + associations = {} --> The associations + return associations def checks(self): - checks = [ - ] + checks = {} --> The config checks return checks + ``` -3. Define _describe functions_ for the ResourceType. These functions will fetch the information you need to then create checks on top of it. For example, if you want to check if an S3 bucket has a public ACL, you first describe the ACLS and then create a function to check if those ACLS are public. This way, you can re-use the describe output for any necessary check. Describe functions in MetaHub are named starting with a `_` as a naming convention. These describe functions will be then be class attributes. +3. Define as many describe functions for the ResourceType you need. These functions will fetch the information you need to then create config checks on top of it. -``` -def _get_bucket_acl(self): +```python +def get_bucket_acl(self): try: response = self.client.get_bucket_acl(Bucket=self.resource_id) except ClientError as err: @@ -74,96 +111,30 @@ def _get_bucket_acl(self): return response["Grants"] ``` -4. Define an attribute for your describe function, in the previous example, we created a function to describe the ACLs (`_get_bucket_acl`) so we will call this attribute `bucket_acl` - -``` -'''MetaCheck: ''' - -from lib.AwsHelpers import get_boto3_client -from lib.metachecks.checks.Base import MetaChecksBase -from lib.context.resources.MetaChecksHelpers import IamHelper +4. Define config check functions to add keys to the config key, and add those functions to the checks function. +```python +def public_dns(self): + public_dns = False + if self.instance: + public_dns = self.instance.get("PublicDnsName") + return public_dns -class Metacheck(MetaChecksBase): - def __init__( - self, - logger, - finding, - metachecks, - mh_filters_checks, - sess, - drilled=False, - ): - self.logger = logger - if metachecks: - self.region = finding["Region"] - self.account = finding["AwsAccountId"] - self.partition = finding["Resources"][0]["Id"].split(":")[1] - self.finding = finding - self.sess = sess - self.resource_arn = finding["Resources"][0]["Id"] - self.resource_id = finding["Resources"][0]["Id"].split("/")[1] - self.mh_filters_checks = mh_filters_checks - self.client = get_boto3_client(self.logger, "ec2", self.region, self.sess) - ) - # Describe functions - self.bucket_acl = self._get_bucket_acl() --> YOUR DESCRIBE FUNCTION AS AN ATTRIBUTE - - def checks(self): - checks = [ - ] - return checks +def checks(self): + checks = { + "public_dns": self.public_dns(), + } + return checks ``` -5. Import Metacheck in metachecks/checks/**init**.py file - -## Creating MetaChecks - -You can code any check you need on top of the data fetched by the _describe functions_. - -A MetaCheck should be defined as a yes/no question; when the answer is yes, we can add extra information. When it is no, we can return False or empty data ("", [], {}). For example, if we check if an S3 ACL is public, we can return the permissions that make that ACL public, like READ or FULL_CONTROL. -When filtering using Meta Checks, we evaluate True as True and True if we return data. So you can output extra information for your resources this way and then integrate it with other tools. As another example, if you are checking a Security Group for unrestrictive open ports, you can output which ports are open and then use that to integrate with Nmap for scanning. +5. Add the associated resources to the associations function. +```python +def associations(self): + associations = { + "iam_roles": self.iam_roles, + } + return associations ``` -def is_bucket_acl_public(self): - public_acls = [] - if self.bucket_acl: - for grant in self.bucket_acl: - if grant["Grantee"]["Type"] == "Group": - who = grant["Grantee"]["URI"].split("/")[-1] - if who == "AllUsers" or who == "AuthenticatedUsers": - perm = grant["Permission"] - public_acls.append(perm) - if public_acls: - return public_acls - return False -``` - -This function will return the permissions allowed to public (like FULL_CONTROL or READ) or will return False if it's not public. -``` -def it_has_bucket_acl_with_cross_account(self): - acl_with_cross_account = [] - if self.bucket_acl: - for grant in self.bucket_acl: - if grant["Grantee"]["Type"] == "CanonicalUser": - if grant["Grantee"]["ID"] != self.cannonical_user_id: - perm = grant["Permission"] - acl_with_cross_account.append(perm) - if acl_with_cross_account: - return acl_with_cross_account - return False -``` - -This function will return the permissions that were granted to other accounts (like FULL_CONTROL or READ) or will return False if it was not granted to other accounts. - -To enable the check, add it to the list in the fuction `checks` of your `ResourceType`. - -``` - def checks(self): - checks = [ - "it_has_bucket_acl_with_cross_account", - "is_bucket_acl_public" - ] - return checks -``` +4. Import Metacheck in lib/resources/**init**.py file diff --git a/docs/imgs/impact.png b/docs/imgs/impact.png new file mode 100644 index 0000000..7bfa62b Binary files /dev/null and b/docs/imgs/impact.png differ diff --git a/lib/actions.py b/lib/actions.py new file mode 100644 index 0000000..104bbe5 --- /dev/null +++ b/lib/actions.py @@ -0,0 +1,97 @@ +from lib.findings import count_mh_findings +from lib.helpers import confirm_choice, print_table, print_title_line + + +class Actions: + def __init__(self, logger, args, mh_findings, sh): + self.logger = logger + self.args = args + self.mh_findings = mh_findings + self.sh = sh + + def update_findings(self, update_filters): + UPProcessedFindings = [] + UPUnprocessedFindings = [] + print_title_line("Update Findings", banners=self.args.banners) + print_table( + "Findings to update: ", + str(count_mh_findings(self.mh_findings)), + banners=self.args.banners, + ) + print_table( + "Update: ", str(self.args.update_findings), banners=self.args.banners + ) + + # Lambda output + if "lambda" in self.args.output_modes: + print( + "Updating findings: ", + str(count_mh_findings(self.mh_findings)), + "with:", + str(self.args.update_findings), + ) + + if self.mh_findings and confirm_choice( + "Are you sure you want to update all findings?", + self.args.actions_confirmation, + ): + update_multiple = self.sh.update_findings_workflow( + self.mh_findings, update_filters + ) + for update in update_multiple: + for ProcessedFinding in update["ProcessedFindings"]: + self.logger.info("Updated Finding : " + ProcessedFinding["Id"]) + UPProcessedFindings.append(ProcessedFinding) + for UnprocessedFinding in update["UnprocessedFindings"]: + self.logger.error( + "Error Updating Finding: " + + UnprocessedFinding["FindingIdentifier"]["Id"] + + " Error: " + + UnprocessedFinding["ErrorMessage"] + ) + UPUnprocessedFindings.append(UnprocessedFinding) + + self.print_processed(UPProcessedFindings, UPUnprocessedFindings) + + def enrich_findings(self): + ENProcessedFindings = [] + ENUnprocessedFindings = [] + print_title_line("Enrich Findings", banners=self.args.banners) + print_table( + "Findings to enrich: ", + str(count_mh_findings(self.mh_findings)), + banners=self.args.banners, + ) + + # Lambda output + if "lambda" in self.args.output_modes: + print("Enriching findings: ", str(count_mh_findings(self.mh_findings))) + + if self.mh_findings and confirm_choice( + "Are you sure you want to enrich all findings?", + self.args.actions_confirmation, + ): + update_multiple = self.sh.update_findings_meta(self.mh_findings) + for update in update_multiple: + for ProcessedFinding in update["ProcessedFindings"]: + self.logger.info("Updated Finding : " + ProcessedFinding["Id"]) + ENProcessedFindings.append(ProcessedFinding) + for UnprocessedFinding in update["UnprocessedFindings"]: + self.logger.error( + "Error Updating Finding: " + + UnprocessedFinding["FindingIdentifier"]["Id"] + + " Error: " + + UnprocessedFinding["ErrorMessage"] + ) + ENUnprocessedFindings.append(UnprocessedFinding) + + self.print_processed(ENProcessedFindings, ENUnprocessedFindings) + + def print_processed(self, processed, unprocessed): + print_title_line("Results", banners=self.args.banners) + print_table( + "ProcessedFindings: ", str(len(processed)), banners=self.args.banners + ) + print_table( + "UnprocessedFindings: ", str(len(unprocessed)), banners=self.args.banners + ) diff --git a/lib/config/impact.yaml b/lib/config/impact.yaml index 09ca298..c515969 100644 --- a/lib/config/impact.yaml +++ b/lib/config/impact.yaml @@ -4,7 +4,7 @@ # Weight: Maximum score for this property # Values: List of values for this property # - Name: Name of the value -# Score: Score for this value +# Score: Score for this value, between 0 and 1. If no match, the property impact property is not scored. status: weight: 50 @@ -21,6 +21,8 @@ status: score: 1 - not-enabled: score: 0 + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # - unknown: # score: 0 @@ -37,6 +39,8 @@ exposure: score: 0.1 - restricted: score: 0 + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # - unknown: # score: 0 @@ -59,7 +63,8 @@ access: score: 0.1 - restricted: score: 0 - # - unknown: + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # score: 0 encryption: @@ -69,7 +74,8 @@ encryption: score: 1 - encrypted: score: 0 - # - unknown: + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # score: 0 environment: @@ -81,7 +87,8 @@ environment: score: 0.3 - development: score: 0 - # - unknown: + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # score: 0 application: @@ -91,7 +98,8 @@ application: score: 1 - app2: score: 0.5 - # - unknown: + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # score: 0 owner: @@ -101,5 +109,6 @@ owner: score: 1 - owner2: score: 0.5 - # - unknown: + # If you want to count unknown as a score of 0, enabling the impact property when no match. + # Uncomment the following lines # score: 0 diff --git a/lib/findings.py b/lib/findings.py index cfe9b51..9e9959c 100644 --- a/lib/findings.py +++ b/lib/findings.py @@ -4,78 +4,12 @@ from alive_progress import alive_bar from lib.context.context import Context -from lib.helpers import confirm_choice, print_table +from lib.helpers import print_table from lib.impact.impact import Impact from lib.securityhub import SecurityHub, parse_finding from lib.statistics import generate_statistics -def update_findings( - logger, - mh_findings, - update, - sh_account, - sh_role, - sh_region, - update_filters, - sh_profile, - actions_confirmation, -): - sh = SecurityHub(logger, sh_region, sh_account, sh_role, sh_profile) - if confirm_choice( - "Are you sure you want to update all findings?", actions_confirmation - ): - update_multiple = sh.update_findings_workflow(mh_findings, update_filters) - update_multiple_ProcessedFinding = [] - update_multiple_UnprocessedFindings = [] - for update in update_multiple: - for ProcessedFinding in update["ProcessedFindings"]: - logger.info("Updated Finding : " + ProcessedFinding["Id"]) - update_multiple_ProcessedFinding.append(ProcessedFinding) - for UnprocessedFinding in update["UnprocessedFindings"]: - logger.error( - "Error Updating Finding: " - + UnprocessedFinding["FindingIdentifier"]["Id"] - + " Error: " - + UnprocessedFinding["ErrorMessage"] - ) - update_multiple_UnprocessedFindings.append(UnprocessedFinding) - return update_multiple_ProcessedFinding, update_multiple_UnprocessedFindings - return [], [] - - -def enrich_findings( - logger, - mh_findings, - sh_account, - sh_role, - sh_region, - sh_profile, - actions_confirmation, -): - sh = SecurityHub(logger, sh_region, sh_account, sh_role, sh_profile) - if confirm_choice( - "Are you sure you want to enrich all findings?", actions_confirmation - ): - update_multiple = sh.update_findings_meta(mh_findings) - update_multiple_ProcessedFinding = [] - update_multiple_UnprocessedFindings = [] - for update in update_multiple: - for ProcessedFinding in update["ProcessedFindings"]: - logger.info("Updated Finding : " + ProcessedFinding["Id"]) - update_multiple_ProcessedFinding.append(ProcessedFinding) - for UnprocessedFinding in update["UnprocessedFindings"]: - logger.error( - "Error Updating Finding: " - + UnprocessedFinding["FindingIdentifier"]["Id"] - + " Error: " - + UnprocessedFinding["ErrorMessage"] - ) - update_multiple_UnprocessedFindings.append(UnprocessedFinding) - return update_multiple_ProcessedFinding, update_multiple_UnprocessedFindings - return [], [] - - def generate_findings( logger, sh_filters, @@ -188,16 +122,13 @@ def process_finding(finding): # Generate Impact imp = Impact(logger) - for resource_arn, resource_values in mh_findings.items(): - impact_checks = imp.generate_impact_checks(resource_arn, resource_values) + for resource_arn in mh_findings: + impact_checks = imp.generate_impact_checks( + resource_arn, mh_findings[resource_arn] + ) mh_findings[resource_arn]["impact"] = mh_findings_short[resource_arn][ "impact" ] = impact_checks - for resource_arn, resource_values in mh_findings.items(): - impact_scoring = imp.generate_impact_scoring(resource_arn, resource_values) - mh_findings[resource_arn]["impact"]["score"] = mh_findings_short[resource_arn][ - "impact" - ]["score"] = impact_scoring # Generate Statistics mh_statistics = generate_statistics(mh_findings) @@ -361,3 +292,10 @@ def evaluate_finding( mh_inventory, AwsAccountData, ) + + +def count_mh_findings(mh_findings): + count = 0 + for resource in mh_findings: + count += len(mh_findings[resource]["findings"]) + return count diff --git a/lib/helpers.py b/lib/helpers.py index 24ca793..f1a8a99 100644 --- a/lib/helpers.py +++ b/lib/helpers.py @@ -1,10 +1,16 @@ import argparse +import json import logging import sys -from rich.panel import Panel - -from lib.AwsHelpers import get_available_regions +from lib.AwsHelpers import ( + get_account_alias, + get_account_id, + get_available_regions, + get_region, +) +from lib.config.configuration import sh_default_filters +from lib.securityhub import set_sh_filters class KeyValueWithList(argparse.Action): @@ -385,68 +391,147 @@ def test_python_version(): return True -def rich_box(resource_type, values): - title = resource_type - value = values - # return f"[b]{title}[/b]\n[yellow]{value}" - return f"[b]{title.center(20)}[/b]\n[bold][yellow]{str(value).center(20)}" - - -def rich_box_severity(severity, values): - color = { - "CRITICAL": "red", - "HIGH": "red", - "MEDIUM": "yellow", - "LOW": "green", - "INFORMATIONAL": "white", - } - title = "[" + color[severity] + "] " + severity + "[/]" - value = values - return f"[b]{title.center(5)}[/b]\n[bold]{str(value).center(5)}" - - -def generate_rich(mh_statistics): - severity_renderables = [] - for severity in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"): - severity_renderables.append( - Panel( - rich_box_severity( - severity, mh_statistics["SeverityLabel"].get(severity, 0) - ), - expand=False, - padding=(1, 10), +def validate_arguments(args, logger): + # Validate no filters when using file-asff only + if "file-asff" in args.inputs and "securityhub" not in args.inputs: + if args.sh_template or args.sh_filters: + logger.error( + "--sh-filters not supported for file-asff... If you want to fetch from securityhub and file-asff at the same time use --inputs file-asff securityhub" + ) + exit(1) + + # Validate file-asff + if "file-asff" in args.inputs: + if not args.input_asff: + logger.error( + "file-asff input specified but not --input-asff, specify the input file with --input-asff" + ) + exit(1) + asff_findings = [] + for file in args.input_asff: + try: + with open(file) as f: + asff_findings.extend(json.load(f)) + except (json.decoder.JSONDecodeError, FileNotFoundError) as err: + logger.error("--input-asff file %s %s!", args.input_asff, str(err)) + exit(1) + elif args.input_asff and "file-asff" not in args.inputs: + logger.error( + "--input-asff specified but not file-asff input. Use --inputs file-asff to use --input-asff" + ) + exit(1) + else: + asff_findings = False + + # Validate Security Hub Filters + if not args.sh_filters and not args.sh_template: + sh_filters = set_sh_filters(sh_default_filters) + elif args.sh_template: + from pathlib import Path + + import yaml + + try: + yaml_to_dict = yaml.safe_load(Path(args.sh_template).read_text()) + dict_values = next(iter(yaml_to_dict.values())) + sh_filters = dict_values + except (yaml.scanner.ScannerError, FileNotFoundError) as err: + logger.error("SH Template %s reading error: %s", args.sh_template, str(err)) + exit(1) + else: + sh_filters = args.sh_filters + sh_filters = set_sh_filters(sh_filters) + + # Validate Config filters + mh_filters_config = args.mh_filters_config or {} + for mh_filter_config_key, mh_filter_config_value in mh_filters_config.items(): + if mh_filters_config[mh_filter_config_key].lower() == "true": + mh_filters_config[mh_filter_config_key] = bool(True) + elif mh_filters_config[mh_filter_config_key].lower() == "false": + mh_filters_config[mh_filter_config_key] = bool(False) + else: + logger.error( + "Only True or False it is supported for Context Config filters: " + + str(mh_filters_config) ) + exit(1) + + # Validate Tags filters + mh_filters_tags = args.mh_filters_tags or {} + + # Parameter Validation: --sh-account and --sh-assume-role + if bool(args.sh_account) != bool(args.sh_assume_role): + logger.error( + "Parameter error: --sh-assume-role and sh-account must be provided together, but only 1 provided." + ) + exit(1) + + # AWS Security Hub + if "securityhub" in args.inputs: + sh_region = args.sh_region or get_region(logger) + sh_account = args.sh_account or get_account_id( + logger, sess=None, profile=args.sh_profile + ) + sh_account_alias = get_account_alias( + logger, sh_account, role_name=args.sh_assume_role, profile=args.sh_profile + ) + else: + sh_region = args.sh_region + sh_account = args.sh_account + sh_account_alias = "" + sh_account_alias_str = ( + " (" + str(sh_account_alias) + ")" if str(sh_account_alias) else "" + ) + + # Validate Security Hub input + if "securityhub" in args.inputs and (not sh_region or not sh_account): + logger.error( + "Security Hub is defined as input for findings, but no region or account was found. Check your credentials and/or use --sh-region and --sh-account." ) - resource_type_renderables = [ - Panel(rich_box(resource_type, values), expand=True) - for resource_type, values in mh_statistics["ResourceType"].items() - ] - workflows_renderables = [ - Panel(rich_box(workflow, values), expand=True) - for workflow, values in mh_statistics["Workflow"].items() - ] - region_renderables = [ - Panel(rich_box(Region, values), expand=True) - for Region, values in mh_statistics["Region"].items() - ] - accountid_renderables = [ - Panel(rich_box(AwsAccountId, values), expand=True) - for AwsAccountId, values in mh_statistics["AwsAccountId"].items() - ] - recordstate_renderables = [ - Panel(rich_box(RecordState, values), expand=True) - for RecordState, values in mh_statistics["RecordState"].items() - ] - compliance_renderables = [ - Panel(rich_box(Compliance, values), expand=True) - for Compliance, values in mh_statistics["Compliance"].items() - ] + exit(1) + + # Validate udpate findings + update_findings_filters = {} + if args.update_findings: + IsNnoteProvided = False + IsAllowedKeyProvided = False + for key, value in args.update_findings.items(): + if key in ("Workflow", "Note"): + if key == "Workflow": + WorkflowValues = ("NEW", "NOTIFIED", "RESOLVED", "SUPPRESSED") + if value not in WorkflowValues: + logger.error( + "Incorrect update findings workflow value. Use: " + + str(WorkflowValues) + ) + exit(1) + Workflow = {"Workflow": {"Status": value}} + update_findings_filters.update(Workflow) + IsAllowedKeyProvided = True + if key == "Note": + Note = {"Note": {"Text": value, "UpdatedBy": "MetaHub"}} + update_findings_filters.update(Note) + IsNnoteProvided = True + continue + logger.error( + "Unsuported update findings key: " + + str(key) + + " - Supported keys: Workflow and Note. Use --update-findings Workflow=NEW Note='This is an example Note'" + ) + exit(1) + if not IsAllowedKeyProvided or not IsNnoteProvided: + logger.error( + 'Update findings missing key. Use --update-findings Workflow=NEW Note="This is an example Note"' + ) + exit(1) + return ( - severity_renderables, - resource_type_renderables, - workflows_renderables, - region_renderables, - accountid_renderables, - recordstate_renderables, - compliance_renderables, + asff_findings, + sh_filters, + mh_filters_config, + mh_filters_tags, + sh_account, + sh_account_alias_str, + sh_region, + update_findings_filters, ) diff --git a/lib/impact/impact.py b/lib/impact/impact.py index faf3a14..6e33ac2 100644 --- a/lib/impact/impact.py +++ b/lib/impact/impact.py @@ -56,18 +56,17 @@ def validate_config(self, config): return True def check_property_values_with_resource( - self, property_name, property_values, resource_values + self, property_name, property_values, impact_dict ): # Check property with resource and return the matching value and score - impact = resource_values.get("impact", {}) - if property_name in impact: + if property_name in impact_dict: for value in property_values: for value_key, value_data in value.items(): - if value_key in impact[property_name]: + if value_key in impact_dict[property_name]: return value_key, value_data["score"] return False - def calculate_properties_score(self, resource_values): + def calculate_properties_score(self, impact_dict): self.logger.info("Calculating impact properties score for resource") # Initialize variables to track the meta score details and context @@ -84,7 +83,7 @@ def calculate_properties_score(self, resource_values): property_weight = self.impact_config[property]["weight"] # Check the property against the finding checked_property = self.check_property_values_with_resource( - property_name, property_values, resource_values + property_name, property_values, impact_dict ) # If the property check is not False (i.e., it has a value), # record the weight, value, and calculated score for this property @@ -119,19 +118,15 @@ def calculate_properties_score(self, resource_values): return meta_score - def generate_impact_scoring(self, resource_arn, resource_values): + def generate_impact_scoring(self, resource_arn, impact_dict): self.logger.info("Calculating impact score for resource") if not self.impact_config: return False - # Calculate the findings score using the calculate_findings_score method - findings_score = Findings(self.logger).get_findings_score( - resource_arn, resource_values - ) - findings_score = [str(key) for key in findings_score.keys()] - findings_score = float(findings_score[0]) + # Get the findings score from the impact dictionary + findings_score = float([str(key) for key in impact_dict["findings"].keys()][0]) # Calculate the impact properties score - meta_score = self.calculate_properties_score(resource_values) + meta_score = self.calculate_properties_score(impact_dict) # Check if the meta score is not "n/a" (i.e., there's context) if meta_score != "n/a" and meta_score != 0: @@ -192,4 +187,7 @@ def generate_impact_checks(self, resource_arn, resource_values): impact_dict["findings"].update( Findings(self.logger).get_findings_score(resource_arn, resource_values) ) + impact_dict["score"].update( + self.generate_impact_scoring(resource_arn, impact_dict) + ) return impact_dict diff --git a/lib/main.py b/lib/main.py index e8c4eb4..bf675dd 100755 --- a/lib/main.py +++ b/lib/main.py @@ -1,188 +1,18 @@ -import json from sys import argv, exit -from rich.columns import Columns -from rich.console import Console - -from lib.AwsHelpers import get_account_alias, get_account_id, get_region -from lib.config.configuration import sh_default_filters -from lib.findings import enrich_findings, generate_findings, update_findings +from lib.actions import Actions +from lib.findings import generate_findings from lib.helpers import ( - generate_rich, get_logger, get_parser, print_banner, - print_color, print_table, print_title_line, test_python_version, + validate_arguments, ) -from lib.outputs import generate_outputs - - -def count_mh_findings(mh_findings): - count = 0 - for resource in mh_findings: - count += len(mh_findings[resource]["findings"]) - return count - - -def set_sh_filters(sh_filters): - """Return filters for AWS Security Hub get_findings Call""" - filters = {} - for key, values in sh_filters.items(): - if key != "self" and values is not None: - filters[key] = [] - for value in values: - value_to_append = {"Comparison": "EQUALS", "Value": value} - filters[key].append(value_to_append) - return filters - - -def validate_arguments(args, logger): - # Validate no filters when using file-asff only - if "file-asff" in args.inputs and "securityhub" not in args.inputs: - if args.sh_template or args.sh_filters: - logger.error( - "--sh-filters not supported for file-asff... If you want to fetch from securityhub and file-asff at the same time use --inputs file-asff securityhub" - ) - exit(1) - - # Validate file-asff - if "file-asff" in args.inputs: - if not args.input_asff: - logger.error( - "file-asff input specified but not --input-asff, specify the input file with --input-asff" - ) - exit(1) - asff_findings = [] - for file in args.input_asff: - try: - with open(file) as f: - asff_findings.extend(json.load(f)) - except (json.decoder.JSONDecodeError, FileNotFoundError) as err: - logger.error("--input-asff file %s %s!", args.input_asff, str(err)) - exit(1) - elif args.input_asff and "file-asff" not in args.inputs: - logger.error( - "--input-asff specified but not file-asff input. Use --inputs file-asff to use --input-asff" - ) - exit(1) - else: - asff_findings = False - - # Validate Security Hub Filters - if not args.sh_filters and not args.sh_template: - sh_filters = set_sh_filters(sh_default_filters) - elif args.sh_template: - from pathlib import Path - - import yaml - - try: - yaml_to_dict = yaml.safe_load(Path(args.sh_template).read_text()) - dict_values = next(iter(yaml_to_dict.values())) - sh_filters = dict_values - except (yaml.scanner.ScannerError, FileNotFoundError) as err: - logger.error("SH Template %s reading error: %s", args.sh_template, str(err)) - exit(1) - else: - sh_filters = args.sh_filters - sh_filters = set_sh_filters(sh_filters) - - # Validate Config filters - mh_filters_config = args.mh_filters_config or {} - for mh_filter_config_key, mh_filter_config_value in mh_filters_config.items(): - if mh_filters_config[mh_filter_config_key].lower() == "true": - mh_filters_config[mh_filter_config_key] = bool(True) - elif mh_filters_config[mh_filter_config_key].lower() == "false": - mh_filters_config[mh_filter_config_key] = bool(False) - else: - logger.error( - "Only True or False it is supported for Context Config filters: " - + str(mh_filters_config) - ) - exit(1) - - # Validate Tags filters - mh_filters_tags = args.mh_filters_tags or {} - - # Parameter Validation: --sh-account and --sh-assume-role - if bool(args.sh_account) != bool(args.sh_assume_role): - logger.error( - "Parameter error: --sh-assume-role and sh-account must be provided together, but only 1 provided." - ) - exit(1) - - # AWS Security Hub - if "securityhub" in args.inputs: - sh_region = args.sh_region or get_region(logger) - sh_account = args.sh_account or get_account_id( - logger, sess=None, profile=args.sh_profile - ) - sh_account_alias = get_account_alias( - logger, sh_account, role_name=args.sh_assume_role, profile=args.sh_profile - ) - else: - sh_region = args.sh_region - sh_account = args.sh_account - sh_account_alias = "" - sh_account_alias_str = ( - " (" + str(sh_account_alias) + ")" if str(sh_account_alias) else "" - ) - - # Validate Security Hub input - if "securityhub" in args.inputs and (not sh_region or not sh_account): - logger.error( - "Security Hub is defined as input for findings, but no region or account was found. Check your credentials and/or use --sh-region and --sh-account." - ) - exit(1) - - # Validate udpate findings - update_findings_filters = {} - if args.update_findings: - IsNnoteProvided = False - IsAllowedKeyProvided = False - for key, value in args.update_findings.items(): - if key in ("Workflow", "Note"): - if key == "Workflow": - WorkflowValues = ("NEW", "NOTIFIED", "RESOLVED", "SUPPRESSED") - if value not in WorkflowValues: - logger.error( - "Incorrect update findings workflow value. Use: " - + str(WorkflowValues) - ) - exit(1) - Workflow = {"Workflow": {"Status": value}} - update_findings_filters.update(Workflow) - IsAllowedKeyProvided = True - if key == "Note": - Note = {"Note": {"Text": value, "UpdatedBy": "MetaHub"}} - update_findings_filters.update(Note) - IsNnoteProvided = True - continue - logger.error( - "Unsuported update findings key: " - + str(key) - + " - Supported keys: Workflow and Note. Use --update-findings Workflow=NEW Note='This is an example Note'" - ) - exit(1) - if not IsAllowedKeyProvided or not IsNnoteProvided: - logger.error( - 'Update findings missing key. Use --update-findings Workflow=NEW Note="This is an example Note"' - ) - exit(1) - - return ( - asff_findings, - sh_filters, - mh_filters_config, - mh_filters_tags, - sh_account, - sh_account_alias_str, - sh_region, - update_findings_filters, - ) +from lib.outputs import Outputs +from lib.securityhub import SecurityHub def main(args): @@ -205,33 +35,37 @@ def main(args): update_findings_filters, ) = validate_arguments(args, logger) + def print_options(): + print_table("Input: ", str(args.inputs), banners=banners) + print_table( + "Security Hub Account: ", + str(sh_account) + sh_account_alias_str, + banners=banners, + ) + print_table("Security Hub Region: ", sh_region, banners=banners) + print_table("Security Hub Role: ", str(args.sh_assume_role), banners=banners) + print_table("Security Hub Profile: ", args.sh_profile, banners=banners) + print_table("Security Hub filters: ", str(sh_filters), banners=banners) + print_table("Security Hub yaml: ", str(args.sh_template), banners=banners) + print_table("Input File: ", str(args.input_asff), banners=banners) + print_table("Context Role: ", str(args.mh_assume_role), banners=banners) + print_table("Context Options: ", str(args.context), banners=banners) + print_table("Config Filters: ", str(mh_filters_config), banners=banners) + print_table("Tags Filters: ", str(mh_filters_tags), banners=banners) + print_table("Update Findings: ", str(args.update_findings), banners=banners) + print_table("Enrich Findings: ", str(args.enrich_findings), banners=banners) + print_table( + "Actions Confirmation: ", str(args.actions_confirmation), banners=banners + ) + print_table("Output Modes: ", str(args.output_modes), banners=banners) + print_table("List Findings: ", str(args.list_findings), banners=banners) + print_table("Log Level: ", str(args.log_level), banners=banners) + + # Options print_title_line("Options", banners=banners) - print_table("Input: ", str(args.inputs), banners=banners) - print_table( - "Security Hub Account: ", - str(sh_account) + sh_account_alias_str, - banners=banners, - ) - print_table("Security Hub Region: ", sh_region, banners=banners) - print_table("Security Hub Role: ", str(args.sh_assume_role), banners=banners) - print_table("Security Hub Profile: ", args.sh_profile, banners=banners) - print_table("Security Hub filters: ", str(sh_filters), banners=banners) - print_table("Security Hub yaml: ", str(args.sh_template), banners=banners) - print_table("Input File: ", str(args.input_asff), banners=banners) - print_table("Context Role: ", str(args.mh_assume_role), banners=banners) - print_table("Context Options: ", str(args.context), banners=banners) - print_table("Config Filters: ", str(mh_filters_config), banners=banners) - print_table("Tags Filters: ", str(mh_filters_tags), banners=banners) - print_table("Update Findings: ", str(args.update_findings), banners=banners) - print_table("Enrich Findings: ", str(args.enrich_findings), banners=banners) - print_table( - "Actions Confirmation: ", str(args.actions_confirmation), banners=banners - ) - print_table("Output Modes: ", str(args.output_modes), banners=banners) - print_table("List Findings: ", str(args.list_findings), banners=banners) - print_table("Log Level: ", str(args.log_level), banners=banners) + print_options() - # Generate Findings + # Reading Findings print_title_line("Reading Findings", banners=banners) ( mh_findings, @@ -254,126 +88,37 @@ def main(args): banners=banners, ) - if mh_findings: - for out in args.list_findings: - print_title_line("List Findings: " + out, banners=banners) - print( - json.dumps( - { - "short": mh_findings_short, - "inventory": mh_inventory, - "statistics": mh_statistics, - "full": mh_findings, - }[out], - indent=2, - ) - ) + # Outputs + outputs = Outputs( + logger, mh_findings, mh_findings_short, mh_inventory, mh_statistics, args + ) + + # List Findings + outputs.list_findings() + # Results print_title_line("Results", banners=banners) - print_table( - "Total Findings: ", str(count_mh_findings(mh_findings)), banners=banners - ) - print_table("Total Resources: ", str(len(mh_findings)), banners=banners) + outputs.show_results() + # Statistics print_title_line("Statistics by Findings", banners=banners) - if banners: - ( - severity_renderables, - resource_type_renderables, - workflows_renderables, - region_renderables, - accountid_renderables, - recordstate_renderables, - compliance_renderables, - ) = generate_rich(mh_statistics) - console = Console() - print_color("Severities:") - # console.print(Align.center(Group(Columns(severity_renderables)))) - console.print(Columns(severity_renderables), end="") - print_color("Resource Type:") - console.print(Columns(resource_type_renderables)) - print_color("Workflow Status:") - console.print(Columns(workflows_renderables)) - print_color("Compliance Status:") - console.print(Columns(compliance_renderables)) - print_color("Record State:") - console.print(Columns(recordstate_renderables)) - print_color("Region:") - console.print(Columns(region_renderables)) - print_color("Account ID:") - console.print(Columns(accountid_renderables)) + outputs.generate_output_rich() + # Outputs Files print_title_line("Outputs", banners=banners) - generate_outputs( - args, - mh_findings_short, - mh_inventory, - mh_statistics, - mh_findings, - banners=banners, - ) + outputs.generate_outputs() if args.update_findings: - UPProcessedFindings = [] - UPUnprocessedFindings = [] - print_title_line("Update Findings", banners=banners) - print_table( - "Findings to update: ", str(count_mh_findings(mh_findings)), banners=banners - ) - print_table("Update: ", str(args.update_findings), banners=banners) - if "lambda" in args.output_modes: - print( - "Updating findings: ", - str(count_mh_findings(mh_findings)), - "with:", - str(args.update_findings), - ) - if mh_findings: - UPProcessedFindings, UPUnprocessedFindings = update_findings( - logger, - mh_findings, - args.update_findings, - sh_account, - args.sh_assume_role, - sh_region, - update_findings_filters, - args.sh_profile, - args.actions_confirmation, - ) - print_title_line("Results", banners=banners) - print_table( - "ProcessedFindings: ", str(len(UPProcessedFindings)), banners=banners - ) - print_table( - "UnprocessedFindings: ", str(len(UPUnprocessedFindings)), banners=banners + sh = SecurityHub( + logger, sh_region, sh_account, args.sh_assume_role, args.sh_profile ) + Actions(logger, args, mh_findings, sh).update_findings(update_findings_filters) if args.enrich_findings: - ENProcessedFindings = [] - ENUnprocessedFindings = [] - print_title_line("Enrich Findings", banners=banners) - print_table( - "Findings to enrich: ", str(count_mh_findings(mh_findings)), banners=banners - ) - if "lambda" in args.output_modes: - print("Enriching findings: ", str(count_mh_findings(mh_findings))) - if mh_findings: - ENProcessedFindings, ENUnprocessedFindings = enrich_findings( - logger, - mh_findings, - sh_account, - args.sh_assume_role, - sh_region, - args.sh_profile, - args.actions_confirmation, - ) - print_title_line("Results", banners=banners) - print_table( - "ProcessedFindings: ", str(len(ENProcessedFindings)), banners=banners - ) - print_table( - "UnprocessedFindings: ", str(len(ENUnprocessedFindings)), banners=banners + sh = SecurityHub( + logger, sh_region, sh_account, args.sh_assume_role, args.sh_profile ) + Actions(logger, args, mh_findings, sh).enrich_findings() if "lambda" in args.output_modes: return mh_findings_short diff --git a/lib/outputs.py b/lib/outputs.py index bdfdff2..4b94440 100644 --- a/lib/outputs.py +++ b/lib/outputs.py @@ -4,43 +4,188 @@ import jinja2 import xlsxwriter +from rich.columns import Columns +from rich.console import Console +from rich.panel import Panel -from lib.config.configuration import outputs_dir, outputs_time_str -from lib.helpers import print_table +from lib.config.configuration import ( + account_columns, + config_columns, + impact_columns, + outputs_dir, + outputs_time_str, + tag_columns, +) +from lib.findings import count_mh_findings +from lib.helpers import print_color, print_table, print_title_line TIMESTRF = strftime(outputs_time_str) -def generate_output_json( - mh_findings_short, mh_findings, mh_inventory, mh_statistics, json_mode, args -): - WRITE_FILE = f"{outputs_dir}metahub-{json_mode}-{TIMESTRF}.json" - with open(WRITE_FILE, "w", encoding="utf-8") as f: - try: - json.dump( - { - "short": mh_findings_short, - "full": mh_findings, - "inventory": mh_inventory, - "statistics": mh_statistics, - }[json_mode], - f, - indent=2, - ) - except ValueError as e: - print("Error generating JSON Output (" + json_mode + "):", e) - print_table("JSON (" + json_mode + "): ", WRITE_FILE, banners=args.banners) +class Outputs: + def __init__( + self, logger, mh_findings, mh_findings_short, mh_inventory, mh_statistics, args + ) -> None: + self.logger = logger + self.mh_findings = mh_findings + self.mh_findings_short = mh_findings_short + self.mh_inventory = mh_inventory + self.mh_statistics = mh_statistics + self.banners = args.banners + self.args = args + # Output Columns + self.config_columns = ( + args.output_config_columns + or config_columns + or list(mh_statistics["config"].keys()) + ) + self.tag_columns = ( + args.output_tag_columns or tag_columns or list(mh_statistics["tags"].keys()) + ) + self.account_columns = ( + args.output_account_columns or account_columns or mh_statistics["account"] + ) + self.impact_columns = impact_columns or mh_statistics["impact"] + + def generate_outputs(self): + if self.mh_findings: + for ouput_mode in self.args.output_modes: + if ouput_mode.startswith("json"): + json_mode = ouput_mode.split("-")[1] + self.generate_output_json( + json_mode, + ) + if ouput_mode == "csv": + self.generate_output_csv() + if ouput_mode == "xlsx": + self.generate_output_xlsx() + if ouput_mode == "html": + self.generate_output_html() + def generate_output_json(self, json_mode): + WRITE_FILE = f"{outputs_dir}metahub-{json_mode}-{TIMESTRF}.json" + with open(WRITE_FILE, "w", encoding="utf-8") as f: + try: + json.dump( + { + "short": self.mh_findings_short, + "full": self.mh_findings, + "inventory": self.mh_inventory, + "statistics": self.mh_statistics, + }[json_mode], + f, + indent=2, + ) + except ValueError as e: + print("Error generating JSON Output (" + json_mode + "):", e) + print_table("JSON (" + json_mode + "): ", WRITE_FILE, banners=self.banners) -def generate_output_csv( - output, config_columns, tag_columns, account_columns, impact_columns, args -): - WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.csv" - with open(WRITE_FILE, "w", encoding="utf-8", newline="") as output_file: + def generate_output_csv(self): + WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.csv" + with open(WRITE_FILE, "w", encoding="utf-8", newline="") as output_file: + colums = [ + "Resource ID", + "Severity", + "Impact", + "Title", + "AWS Account ID", + "Region", + "Resource Type", + "WorkflowStatus", + "RecordState", + "ComplianceStatus", + ] + colums = ( + colums + + self.config_columns + + self.tag_columns + + self.account_columns + + self.impact_columns + ) + dict_writer = csv.DictWriter(output_file, fieldnames=colums) + dict_writer.writeheader() + # Iterate over the resources + for resource, values in self.mh_findings.items(): + for finding in values["findings"]: + for f, v in finding.items(): + tag_column_values = [] + for column in self.tag_columns: + try: + tag_column_values.append(values["tags"][column]) + except (KeyError, TypeError): + tag_column_values.append("") + config_column_values = [] + for column in self.config_columns: + try: + config_column_values.append(values["config"][column]) + except (KeyError, TypeError): + config_column_values.append("") + impact_column_values = [] + for column in self.impact_columns: + try: + impact_column_values.append(values["impact"][column]) + except (KeyError, TypeError): + impact_column_values.append("") + account_column_values = [] + for column in self.account_columns: + try: + account_column_values.append(values["account"][column]) + except (KeyError, TypeError): + account_column_values.append("") + row = ( + [ + resource, + v.get("SeverityLabel", None), + values.get("impact", None).get("Impact", None) + if values.get("impact") + else None, + f, + values.get("AwsAccountId", None), + values.get("Region", None), + values.get("ResourceType", None), + v.get("Workflow", {}).get("Status", None) + if v.get("Workflow") + else None, + v.get("RecordState", None), + v.get("Compliance", {}).get("Status", None) + if v.get("Compliance") + else None, + ] + # + impact_column_values + + account_column_values + + tag_column_values + + config_column_values + ) + dict_writer.writerow(dict(zip(colums, row))) + print_table("CSV: ", WRITE_FILE, banners=self.banners) + + def generate_output_xlsx(self): + WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.xlsx" + # Create a workbook and add a worksheet + workbook = xlsxwriter.Workbook(WRITE_FILE) + worksheet = workbook.add_worksheet("findings") + # Columns + worksheet.set_default_row(25) + worksheet.set_column(0, 0, 145) # Resource ID. + worksheet.set_column(1, 1, 15) # Severity. + worksheet.set_column(2, 2, 15) # Impact. + worksheet.set_column(3, 3, 105) # Title. + worksheet.set_column(4, 4, 15) # Account ID. + worksheet.set_column(5, 5, 15) # Region. + worksheet.set_column(6, 6, 25) # Resource Type. + worksheet.set_column(7, 7, 25) # WorkflowStatus. + worksheet.set_column(8, 8, 25) # RecordState. + worksheet.set_column(9, 9, 25) # ComplianceStatus. + # Formats + title_format = workbook.add_format({"bold": True, "border": 1}) + raws_format = workbook.add_format({"text_wrap": True, "border": 1}) + critical_format = workbook.add_format({"bg_color": "#7d2105", "border": 1}) + high_format = workbook.add_format({"bg_color": "#ba2e0f", "border": 1}) + medium_format = workbook.add_format({"bg_color": "#cc6021", "border": 1}) + low_format = workbook.add_format({"bg_color": "#b49216", "border": 1}) colums = [ "Resource ID", "Severity", - "Impact", "Title", "AWS Account ID", "Region", @@ -49,46 +194,57 @@ def generate_output_csv( "RecordState", "ComplianceStatus", ] - colums = ( - colums + config_columns + tag_columns + account_columns + impact_columns + worksheet.write_row( + 0, + 0, + colums + + self.config_columns + + self.tag_columns + + self.account_columns + + self.impact_columns, + title_format, ) - dict_writer = csv.DictWriter(output_file, fieldnames=colums) - dict_writer.writeheader() # Iterate over the resources - for resource, values in output.items(): + current_line = 1 + for resource, values in self.mh_findings.items(): for finding in values["findings"]: for f, v in finding.items(): + worksheet.write_row(current_line, 0, [resource], raws_format) + severity = v["SeverityLabel"] + if severity == "CRITICAL": + worksheet.write(current_line, 1, severity, critical_format) + elif severity == "HIGH": + worksheet.write(current_line, 1, severity, high_format) + elif severity == "MEDIUM": + worksheet.write(current_line, 1, severity, medium_format) + else: + worksheet.write(current_line, 1, severity, low_format) tag_column_values = [] - for column in tag_columns: + for column in self.tag_columns: try: tag_column_values.append(values["tags"][column]) except (KeyError, TypeError): tag_column_values.append("") config_column_values = [] - for column in config_columns: + for column in self.config_columns: try: config_column_values.append(values["config"][column]) except (KeyError, TypeError): config_column_values.append("") impact_column_values = [] - for column in impact_columns: + for column in self.impact_columns: try: impact_column_values.append(values["impact"][column]) except (KeyError, TypeError): impact_column_values.append("") account_column_values = [] - for column in account_columns: + for column in self.account_columns: try: account_column_values.append(values["account"][column]) except (KeyError, TypeError): account_column_values.append("") row = ( [ - resource, - v.get("SeverityLabel", None), - values.get("impact", None).get("Impact", None) - if values.get("impact") - else None, f, values.get("AwsAccountId", None), values.get("Region", None), @@ -106,219 +262,163 @@ def generate_output_csv( + tag_column_values + config_column_values ) - dict_writer.writerow(dict(zip(colums, row))) - print_table("CSV: ", WRITE_FILE, banners=args.banners) + worksheet.write_row(current_line, 2, row) + current_line += 1 + workbook.close() + print_table("XLSX: ", WRITE_FILE, banners=self.banners) + def generate_output_html(self): + WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.html" + templateLoader = jinja2.FileSystemLoader(searchpath="./") + templateEnv = jinja2.Environment(loader=templateLoader, autoescape=True) + TEMPLATE_FILE = "lib/html/template.html" + template = templateEnv.get_template(TEMPLATE_FILE) + # Convert Config to Boolean + for resource_arn in self.mh_findings: + keys_to_convert = ["config", "associations"] + for key in keys_to_convert: + if ( + key in self.mh_findings[resource_arn] + and self.mh_findings[resource_arn][key] + ): + for config in self.mh_findings[resource_arn][key]: + if bool(self.mh_findings[resource_arn][key][config]): + self.mh_findings[resource_arn][key][config] = True + else: + self.mh_findings[resource_arn][key][config] = False -def generate_output_xlsx( - output, config_columns, tag_columns, account_columns, impact_columns, args -): - WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.xlsx" - # Create a workbook and add a worksheet - workbook = xlsxwriter.Workbook(WRITE_FILE) - worksheet = workbook.add_worksheet("findings") - # Columns - worksheet.set_default_row(25) - worksheet.set_column(0, 0, 145) # Resource ID. - worksheet.set_column(1, 1, 15) # Severity. - worksheet.set_column(2, 2, 15) # Impact. - worksheet.set_column(3, 3, 105) # Title. - worksheet.set_column(4, 4, 15) # Account ID. - worksheet.set_column(5, 5, 15) # Region. - worksheet.set_column(6, 6, 25) # Resource Type. - worksheet.set_column(7, 7, 25) # WorkflowStatus. - worksheet.set_column(8, 8, 25) # RecordState. - worksheet.set_column(9, 9, 25) # ComplianceStatus. - # Formats - title_format = workbook.add_format({"bold": True, "border": 1}) - raws_format = workbook.add_format({"text_wrap": True, "border": 1}) - critical_format = workbook.add_format({"bg_color": "#7d2105", "border": 1}) - high_format = workbook.add_format({"bg_color": "#ba2e0f", "border": 1}) - medium_format = workbook.add_format({"bg_color": "#cc6021", "border": 1}) - low_format = workbook.add_format({"bg_color": "#b49216", "border": 1}) - colums = [ - "Resource ID", - "Severity", - "Title", - "AWS Account ID", - "Region", - "Resource Type", - "WorkflowStatus", - "RecordState", - "ComplianceStatus", - ] - worksheet.write_row( - 0, - 0, - colums + config_columns + tag_columns + account_columns + impact_columns, - title_format, - ) - # Iterate over the resources - current_line = 1 - for resource, values in output.items(): - for finding in values["findings"]: - for f, v in finding.items(): - worksheet.write_row(current_line, 0, [resource], raws_format) - severity = v["SeverityLabel"] - if severity == "CRITICAL": - worksheet.write(current_line, 1, severity, critical_format) - elif severity == "HIGH": - worksheet.write(current_line, 1, severity, high_format) - elif severity == "MEDIUM": - worksheet.write(current_line, 1, severity, medium_format) - else: - worksheet.write(current_line, 1, severity, low_format) - tag_column_values = [] - for column in tag_columns: - try: - tag_column_values.append(values["tags"][column]) - except (KeyError, TypeError): - tag_column_values.append("") - config_column_values = [] - for column in config_columns: - try: - config_column_values.append(values["config"][column]) - except (KeyError, TypeError): - config_column_values.append("") - impact_column_values = [] - for column in impact_columns: - try: - impact_column_values.append(values["impact"][column]) - except (KeyError, TypeError): - impact_column_values.append("") - account_column_values = [] - for column in account_columns: - try: - account_column_values.append(values["account"][column]) - except (KeyError, TypeError): - account_column_values.append("") - row = ( - [ - f, - values.get("AwsAccountId", None), - values.get("Region", None), - values.get("ResourceType", None), - v.get("Workflow", {}).get("Status", None) - if v.get("Workflow") - else None, - v.get("RecordState", None), - v.get("Compliance", {}).get("Status", None) - if v.get("Compliance") - else None, - ] - # + impact_column_values - + account_column_values - + tag_column_values - + config_column_values - ) - worksheet.write_row(current_line, 2, row) - current_line += 1 - workbook.close() - print_table("XLSX: ", WRITE_FILE, banners=args.banners) + with open(WRITE_FILE, "w", encoding="utf-8") as f: + html = template.render( + data=self.mh_findings, + statistics=self.mh_statistics, + title="MetaHub", + config_columns=self.config_columns, + tag_columns=self.tag_columns, + account_columns=self.account_columns, + impact_columns=self.impact_columns, + parameters=self.args, + ) + f.write(html) + print_table("HTML: ", WRITE_FILE, banners=self.banners) -def generate_output_html( - mh_findings, - mh_statistics, - config_columns, - tag_columns, - account_columns, - impact_columns, - args, -): - WRITE_FILE = f"{outputs_dir}metahub-{TIMESTRF}.html" - templateLoader = jinja2.FileSystemLoader(searchpath="./") - templateEnv = jinja2.Environment(loader=templateLoader, autoescape=True) - TEMPLATE_FILE = "lib/html/template.html" - template = templateEnv.get_template(TEMPLATE_FILE) - # Convert Config to Boolean - for resource_arn in mh_findings: - keys_to_convert = ["config", "associations"] - for key in keys_to_convert: - if key in mh_findings[resource_arn] and mh_findings[resource_arn][key]: - for config in mh_findings[resource_arn][key]: - if bool(mh_findings[resource_arn][key][config]): - mh_findings[resource_arn][key][config] = True - else: - mh_findings[resource_arn][key][config] = False + def generate_output_rich(self): + if self.banners: + ( + severity_renderables, + resource_type_renderables, + workflows_renderables, + region_renderables, + accountid_renderables, + recordstate_renderables, + compliance_renderables, + ) = generate_rich(self.mh_statistics) + console = Console() + print_color("Severities:") + # console.print(Align.center(Group(Columns(severity_renderables)))) + console.print(Columns(severity_renderables), end="") + print_color("Resource Type:") + console.print(Columns(resource_type_renderables)) + print_color("Workflow Status:") + console.print(Columns(workflows_renderables)) + print_color("Compliance Status:") + console.print(Columns(compliance_renderables)) + print_color("Record State:") + console.print(Columns(recordstate_renderables)) + print_color("Region:") + console.print(Columns(region_renderables)) + print_color("Account ID:") + console.print(Columns(accountid_renderables)) - with open(WRITE_FILE, "w", encoding="utf-8") as f: - html = template.render( - data=mh_findings, - statistics=mh_statistics, - title="MetaHub", - config_columns=config_columns, - tag_columns=tag_columns, - account_columns=account_columns, - impact_columns=impact_columns, - parameters=args, + def show_results(self): + print_table( + "Total Findings: ", + str(count_mh_findings(self.mh_findings)), + banners=self.banners, + ) + print_table( + "Total Resources: ", str(len(self.mh_findings)), banners=self.banners ) - f.write(html) - print_table("HTML: ", WRITE_FILE, banners=args.banners) + def list_findings(self): + if self.mh_findings: + for out in self.args.list_findings: + print_title_line("List Findings: " + out, banners=self.banners) + print( + json.dumps( + { + "short": self.mh_findings_short, + "inventory": self.mh_inventory, + "statistics": self.mh_statistics, + "full": self.mh_findings, + }[out], + indent=2, + ) + ) -def generate_outputs( - args, mh_findings_short, mh_inventory, mh_statistics, mh_findings, banners -): - from lib.config.configuration import ( - account_columns, - config_columns, - impact_columns, - tag_columns, - ) +def rich_box(resource_type, values): + title = resource_type + value = values + # return f"[b]{title}[/b]\n[yellow]{value}" + return f"[b]{title.center(20)}[/b]\n[bold][yellow]{str(value).center(20)}" - # Columns for CSV and HTML - output_config_columns = ( - args.output_config_columns - or config_columns - or list(mh_statistics["config"].keys()) - ) - output_tag_columns = ( - args.output_tag_columns or tag_columns or list(mh_statistics["tags"].keys()) - ) - output_account_columns = ( - args.output_account_columns or account_columns or mh_statistics["account"] - ) - output_impact_columns = impact_columns or mh_statistics["impact"] - if mh_findings: - for ouput_mode in args.output_modes: - if ouput_mode.startswith("json"): - json_mode = ouput_mode.split("-")[1] - generate_output_json( - mh_findings_short, - mh_findings, - mh_inventory, - mh_statistics, - json_mode, - args, - ) - if ouput_mode == "html": - generate_output_html( - mh_findings, - mh_statistics, - output_config_columns, - output_tag_columns, - output_account_columns, - output_impact_columns, - args, - ) - if ouput_mode == "csv": - generate_output_csv( - mh_findings, - output_config_columns, - output_tag_columns, - output_account_columns, - output_impact_columns, - args, - ) - if ouput_mode == "xlsx": - generate_output_xlsx( - mh_findings, - output_config_columns, - output_tag_columns, - output_account_columns, - output_impact_columns, - args, - ) +def rich_box_severity(severity, values): + color = { + "CRITICAL": "red", + "HIGH": "red", + "MEDIUM": "yellow", + "LOW": "green", + "INFORMATIONAL": "white", + } + title = "[" + color[severity] + "] " + severity + "[/]" + value = values + return f"[b]{title.center(5)}[/b]\n[bold]{str(value).center(5)}" + + +def generate_rich(mh_statistics): + severity_renderables = [] + for severity in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"): + severity_renderables.append( + Panel( + rich_box_severity( + severity, mh_statistics["SeverityLabel"].get(severity, 0) + ), + expand=False, + padding=(1, 10), + ) + ) + resource_type_renderables = [ + Panel(rich_box(resource_type, values), expand=True) + for resource_type, values in mh_statistics["ResourceType"].items() + ] + workflows_renderables = [ + Panel(rich_box(workflow, values), expand=True) + for workflow, values in mh_statistics["Workflow"].items() + ] + region_renderables = [ + Panel(rich_box(Region, values), expand=True) + for Region, values in mh_statistics["Region"].items() + ] + accountid_renderables = [ + Panel(rich_box(AwsAccountId, values), expand=True) + for AwsAccountId, values in mh_statistics["AwsAccountId"].items() + ] + recordstate_renderables = [ + Panel(rich_box(RecordState, values), expand=True) + for RecordState, values in mh_statistics["RecordState"].items() + ] + compliance_renderables = [ + Panel(rich_box(Compliance, values), expand=True) + for Compliance, values in mh_statistics["Compliance"].items() + ] + return ( + severity_renderables, + resource_type_renderables, + workflows_renderables, + region_renderables, + accountid_renderables, + recordstate_renderables, + compliance_renderables, + ) diff --git a/lib/securityhub.py b/lib/securityhub.py index 2fee821..1066de1 100644 --- a/lib/securityhub.py +++ b/lib/securityhub.py @@ -202,3 +202,15 @@ def parse_finding(finding): }, } return finding["Resources"][0]["Id"], findings + + +def set_sh_filters(sh_filters): + """Return filters for AWS Security Hub get_findings Call""" + filters = {} + for key, values in sh_filters.items(): + if key != "self" and values is not None: + filters[key] = [] + for value in values: + value_to_append = {"Comparison": "EQUALS", "Value": value} + filters[key].append(value_to_append) + return filters