├── .circleci └── config.yml ├── .data └── reach-demo.gif ├── .gitignore ├── LICENSE ├── README.md ├── build.sh ├── cmd ├── assert.go ├── exit.go ├── root.go └── warnings.go ├── go.mod ├── go.sum ├── main.go ├── main_windows.go └── reach ├── acceptance ├── acceptance.go ├── data │ ├── golden │ │ └── analysis_subjects_two_ec2_instances.json │ └── tf │ │ ├── ami_ubuntu.tf │ │ ├── ec2_instances_same_subnet_all_traffic.tf │ │ ├── ec2_instances_same_subnet_https_via_two-way_sg_ip_match.tf │ │ ├── ec2_instances_same_subnet_multiple_protocols.tf │ │ ├── ec2_instances_same_subnet_no_security_group_rules.tf │ │ ├── ec2_instances_same_subnet_ssh.tf │ │ ├── ec2_instances_same_subnet_udp_dns_via_sg_reference.tf │ │ ├── ec2_instances_same_vpc_all_esp.tf │ │ ├── ec2_instances_same_vpc_all_traffic.tf │ │ ├── ec2_instances_same_vpc_postgres.tf │ │ ├── main.tf │ │ ├── network_acl_both_subnets_all_tcp.tf │ │ ├── network_acl_both_subnets_all_traffic.tf │ │ ├── network_acl_both_subnets_no_traffic.tf │ │ ├── network_acl_destination_subnet_tightened_postgres.tf │ │ ├── network_acl_source_subnet_tightened_postgres.tf │ │ ├── outputs.tf │ │ ├── security_group_inbound_allow_all.tf │ │ ├── security_group_inbound_allow_esp.tf │ │ ├── security_group_inbound_allow_https_from_ip.tf │ │ ├── security_group_inbound_allow_postgres_from_sg_no_rules.tf │ │ ├── security_group_inbound_allow_ssh.tf │ │ ├── security_group_inbound_allow_udp_dns_from_sg_no_rules.tf │ │ ├── security_group_no_rules.tf │ │ ├── security_group_outbound_allow_all.tf │ │ ├── security_group_outbound_allow_all_tcp.tf │ │ ├── security_group_outbound_allow_all_udp_to_sg_no_rules.tf │ │ ├── security_group_outbound_allow_esp.tf │ │ ├── security_group_outbound_allow_https_to_ip.tf │ │ ├── security_group_outbound_allow_postgres_to_sg_no_rules.tf │ │ ├── subnet_pair.tf │ │ ├── subnet_single.tf │ │ └── vpc.tf ├── template.go └── terraform │ └── terraform.go ├── analysis.go ├── analyzer ├── analyzer.go └── analyzer_test.go ├── aws ├── api │ ├── ec2_instance.go │ ├── elastic_network_interface.go │ ├── network_acl.go │ ├── resource_provider.go │ ├── route_table.go │ ├── security_group.go │ ├── security_group_reference.go │ ├── subnet.go │ └── vpc.go ├── aws.go ├── ec2_instance.go ├── ec2_instance_subject.go ├── ec2_instance_subject_test.go ├── elastic_network_interface.go ├── explainer.go ├── factors.go ├── find_ec2_instance_id.go ├── find_ec2_instance_id_test.go ├── instance_state_factor.go ├── is_used_by.go ├── lineage.go ├── network_acl.go ├── network_acl_rule.go ├── network_acl_rule_direction.go ├── network_acl_rule_explanation_view_model.go ├── network_acl_rule_match.go ├── network_acl_rules_factor.go ├── network_acl_rules_factor_component.go ├── network_interface_attachment.go ├── new_subject.go ├── perspective.go ├── resource_provider.go ├── route_table.go ├── route_table_route.go ├── security_group.go ├── security_group_reference.go ├── security_group_rule.go ├── security_group_rule_direction.go ├── security_group_rule_explanation_view_model.go ├── security_group_rule_match.go ├── security_group_rule_match_basis.go ├── security_group_rules_factor.go ├── security_group_rules_factor_component.go ├── subnet.go ├── vector_analyzer.go ├── vector_discoverer.go └── vpc.go ├── custom_protocols.go ├── explainer └── explainer.go ├── factor.go ├── helper └── strings.go ├── network_point.go ├── network_vector.go ├── perspective.go ├── protocol.go ├── protocol_content.go ├── resource.go ├── resource_collection.go ├── resource_reference.go ├── set ├── icmp_set.go ├── icmp_type_code.go ├── port_set.go ├── range.go ├── set.go └── set_test.go ├── subject.go ├── testing.go ├── traffic_content.go ├── vector_analyzer.go └── vector_discoverer.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | - image: circleci/golang:1.13.1 9 | working_directory: ~/repo 10 | steps: 11 | - checkout 12 | - run: ./build.sh 13 | 14 | unit_tests: 15 | docker: 16 | - image: circleci/golang:1.13.1 17 | working_directory: ~/repo 18 | steps: 19 | - checkout 20 | - run: go test -v -cover ./reach/... 21 | 22 | acceptance_tests: 23 | docker: 24 | - image: circleci/golang:1.13.1 25 | working_directory: ~/repo 26 | steps: 27 | - checkout 28 | - run: curl -sS https://releases.hashicorp.com/terraform/0.12.15/terraform_0.12.15_linux_amd64.zip -o ./terraform.zip && unzip terraform.zip && sudo mv terraform /usr/local/bin/ && which terraform 29 | - run: go test -cover ./reach/analyzer -test.v -acceptance -log-tf -timeout 60m 30 | 31 | workflows: 32 | version: 2 33 | push: 34 | jobs: 35 | - build 36 | - unit_tests 37 | nightly: 38 | triggers: 39 | - schedule: 40 | cron: "0 0 * * *" 41 | filters: 42 | branches: 43 | only: 44 | - master 45 | jobs: 46 | - acceptance_tests 47 | -------------------------------------------------------------------------------- /.data/reach-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luhring/reach/e5f58e13c27c2ec7706b008e63fa724b1e3b1b74/.data/reach-demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Personal GoLand settings 2 | .idea/* 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Build output of any kind 18 | build/* 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dan Luhring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reach 2 | 3 | [![CircleCI](https://circleci.com/gh/luhring/reach.svg?style=svg)](https://circleci.com/gh/luhring/reach) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/luhring/reach)](https://goreportcard.com/report/github.com/luhring/reach) 5 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/luhring/reach/blob/master/LICENSE) 6 | 7 | Reach is a tool for analyzing the network traffic allowed to flow in AWS. Reach doesn't need any access to your network — it simply queries the AWS API for your network configuration. 8 | 9 | ## Getting Started 10 | 11 | To perform an analysis, specify a **source** EC2 instance and a **destination** EC2 instance: 12 | 13 | ```Text 14 | $ reach 15 | ``` 16 | 17 | ![Image](.data/reach-demo.gif) 18 | 19 | Reach uses your AWS configuration to analyze the potential for network connectivity between two EC2 instances in your AWS account. This means **you don't need to install Reach on any servers** — you just need access to the AWS API. 20 | 21 | The key benefits of Reach are: 22 | 23 | - **Solve problems faster:** Find missing links in a network path in _seconds_, not hours. 24 | 25 | - **Don't compromise on security:** Secure your network without worrying about impacting any required network flows. 26 | 27 | - **Learn about your network:** Gain better insight into currently allowed network flows, and discover new consequences of your network design. 28 | 29 | - **Build better pipelines:** Discover network-level problems before running application integration or end-to-end tests by adding Reach to your CI/CD pipelines. 30 | 31 | ## Basic Usage 32 | 33 | The values for `source` and `destination` should each uniquely identify an EC2 instance in your AWS account. You can use an **instance ID** or a **name tag**, and you can enter just the first few characters instead of the entire value, as long as what you've entered matches exactly one EC2 instance. 34 | 35 | Some examples: 36 | 37 | ```Text 38 | $ reach i-0452993c7efa3a314 i-02b8dfb5537e80860 39 | ``` 40 | 41 | ```Text 42 | $ reach i-04 i-02 43 | ``` 44 | 45 | ```Text 46 | $ reach web-instance database-instance 47 | ``` 48 | 49 | ```Text 50 | $ reach web data 51 | ``` 52 | 53 | **Note:** Right now, Reach can analyze the path between two EC2 instances only when the instances are **_in the same VPC_**. 54 | 55 | ## Initial Setup 56 | 57 | If you've never used Reach before, download the latest version for your platform from the [Releases](https://github.com/luhring/reach/releases) page. (Alternatively, if you've installed the [Go tools](https://golang.org/dl/), you can clone this repository and build from source.) 58 | 59 | You need to run Reach from somewhere where you've saved AWS credentials for your AWS account. Reach follows the standard process for locating and using AWS credentials, similar to the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) tool and other AWS-capable tools (e.g. Terraform). If you're not sure how to set up AWS credentials, check out [AWS's documentation for setting up credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html). 60 | 61 | Once you've set up AWS credentials, you'll need to make sure your IAM user or role has permission to access the necessary resources in your AWS account. Reach only ever needs **read-only** access, it never modifies any resources in your AWS account. Reach makes various requests to the AWS API to describe various network-related resources, such as EC2 instances, VPCs, subnets, security groups, etc. 62 | 63 | ## More Features 64 | 65 | ### Assertions 66 | 67 | If you deploy infrastructure via CI/CD pipelines, it can be helpful to validate the network design itself before running any tests that rely on a correct network configuration. 68 | 69 | You can use assertion flags to ensure that your source **can** or **cannot** reach your destination. 70 | 71 | If an assertion succeeds, Reach exits `0`. If an assertion fails, Reach exits `2`. 72 | 73 | To confirm that the source **can** reach the destination: 74 | 75 | ```Text 76 | $ reach web-server database-server --assert-reachable 77 | ``` 78 | 79 | To confirm that the source **cannot** reach the destination: 80 | 81 | ```Text 82 | $ reach some-server super-sensitive-server --assert-not-reachable 83 | ``` 84 | 85 | ### Explanations 86 | 87 | Normally, Reach's output is very basic. It displays a simple list of zero or more kinds of network traffic that are allowed to flow from the source to the destination. However, the process Reach uses to perform its analysis is more complex. 88 | 89 | If you're troubleshooting a network problem in AWS, it's probably more helpful to see _"why"_ the analysis result is what it is. 90 | 91 | You can tell Reach to expose the reasoning behind the displayed result by using the `--explain` flag: 92 | 93 | ```Text 94 | $ reach web-instance db-instance --explain 95 | ``` 96 | 97 | In this case, Reach will provide significantly more detail about the analysis. Specificially, the output will also show you: 98 | 99 | - Exactly which "network points" were used in the analysis (not just the EC2 instance, but the EC2 instance's specific network interface, and the specific IP address attached to the network interface) 100 | - All of the "factors" (relevant aspects of your configuration) Reach used to figure out what traffic is being allowed by specific properties of your resources (e.g. security group rules, instance state, etc.) 101 | 102 | ## Feature Ideas 103 | 104 | - ~~**Same-subnet analysis:** Between two EC2 instances within the same subnet~~ (done!) 105 | - ~~**Same-VPC analysis:** Between two EC2 instances within the same VPC, including for EC2 instances in separate subnets~~ (done!) 106 | - **IP address analysis:** Between an EC2 instance and a specified IP address that may be outside of AWS entirely (enhancement idea: provide shortcuts for things like the user's own IP address, a specified hostname's resolved IP address, etc.) 107 | - **Filtered analysis:** Specify a particular kind of network traffic to analyze (e.g. a single TCP port) and return results only for that filter 108 | - **Other AWS resources:** Analyze other kinds of AWS resources than just EC2 instances (e.g. ELB, Lambda, VPC endpoints, etc.) 109 | - **Peered VPC analysis**: Between resources from separate but peered VPCs 110 | - Other things! Your ideas are welcome! 111 | 112 | ## Disclaimers 113 | 114 | - This tool is a work in progress! Use at your own risk, and please submit issues as you encounter bugs or have feature requests. 115 | 116 | - Because Reach gets all of its information from the AWS API, Reach makes no guarantees about network service accessibility with respect to the operating system or applications running on a host within the cloud environment. In other words, Reach can tell you if your VPC resources and EC2 instances are configured correctly, but Reach _cannot_ tell you if an OS firewall is blocking network traffic, or if an application listening on a port has crashed. 117 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script takes an argument for which OS to build for: darwin, linux, or windows. 4 | # If no argument is provided, the script builds for all three. 5 | 6 | # To build for a specific version, set the `REACH_VERSION` variable to something like "2.0.1" before running the script. 7 | 8 | set -e 9 | 10 | export REACH_VERSION=${REACH_VERSION:-"0.0.0"} 11 | export SPECIFIED_OS="" 12 | 13 | if [[ -z "$1" ]] 14 | then 15 | export SPECIFIED_OS="$1" 16 | fi 17 | 18 | set -u 19 | 20 | export CGO_ENABLED=0 21 | export GOARCH=amd64 22 | 23 | set -x 24 | 25 | function build_for_os { 26 | local GOOS="$1" 27 | local REACH_EXECUTABLE 28 | 29 | if [[ "$GOOS" == "windows" ]] 30 | then 31 | REACH_EXECUTABLE="reach.exe" 32 | else 33 | REACH_EXECUTABLE="reach" 34 | fi 35 | 36 | local REACH_DIR_FOR_OS 37 | REACH_DIR_FOR_OS=$(printf "reach_%s_%s_amd64" "$REACH_VERSION" "$GOOS") 38 | 39 | mkdir -p "./$REACH_DIR_FOR_OS" 40 | 41 | GOOS=$GOOS go build -a -v -tags netgo -o "./$REACH_DIR_FOR_OS/$REACH_EXECUTABLE" .. 42 | cp -nv ../LICENSE ../README.md "./$REACH_DIR_FOR_OS/" 43 | 44 | if [[ "$GOOS" == "windows" ]] 45 | then 46 | zip "$REACH_DIR_FOR_OS.zip" "./$REACH_DIR_FOR_OS"/* 47 | openssl dgst -sha256 "./$REACH_DIR_FOR_OS.zip" >> ./checksums.txt 48 | else 49 | tar -cvzf "$REACH_DIR_FOR_OS.tar.gz" "./$REACH_DIR_FOR_OS"/* 50 | openssl dgst -sha256 "./$REACH_DIR_FOR_OS.tar.gz" >> ./checksums.txt 51 | fi 52 | } 53 | 54 | rm -rf ./build 55 | mkdir -p ./build 56 | 57 | pushd ./build 58 | if [[ ! -z "$SPECIFIED_OS" ]] 59 | then 60 | build_for_os "$SPECIFIED_OS" 61 | else 62 | for CURRENT_OS in "darwin" "linux" "windows" 63 | do 64 | build_for_os "$CURRENT_OS" 65 | done 66 | fi 67 | 68 | set +x 69 | 70 | cat ./checksums.txt 71 | popd 72 | 73 | set +eu 74 | -------------------------------------------------------------------------------- /cmd/assert.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mgutz/ansi" 8 | 9 | "github.com/luhring/reach/reach" 10 | ) 11 | 12 | func doAssertReachable(analysis reach.Analysis) { 13 | if analysis.PassesAssertReachable() { 14 | exitSuccessfulAssertion("source is able to reach destination") 15 | } else { 16 | exitFailedAssertion("one or more forward or return paths of network traffic is obstructed") 17 | } 18 | } 19 | 20 | func doAssertNotReachable(analysis reach.Analysis) { 21 | if analysis.PassesAssertNotReachable() { 22 | exitSuccessfulAssertion("source is unable to reach destination") 23 | } else { 24 | exitFailedAssertion("source is able to send network traffic to destination") 25 | } 26 | } 27 | 28 | func exitFailedAssertion(text string) { 29 | failedMessage := ansi.Color("assertion failed:", "red+b") 30 | secondaryMessage := ansi.Color(text, "red") 31 | _, _ = fmt.Fprintf(os.Stderr, "\n%v %v\n", failedMessage, secondaryMessage) 32 | 33 | os.Exit(2) 34 | } 35 | 36 | func exitSuccessfulAssertion(text string) { 37 | succeededMessage := ansi.Color("assertion succeeded:", "green+b") 38 | secondaryMessage := ansi.Color(text, "green") 39 | _, _ = fmt.Fprintf(os.Stderr, "\n%v %v\n", succeededMessage, secondaryMessage) 40 | 41 | os.Exit(0) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/exit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func exitWithError(err error) { 9 | _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) 10 | 11 | os.Exit(1) 12 | } 13 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/luhring/reach/reach/analyzer" 11 | "github.com/luhring/reach/reach/aws" 12 | "github.com/luhring/reach/reach/aws/api" 13 | "github.com/luhring/reach/reach/explainer" 14 | ) 15 | 16 | const explainFlag = "explain" 17 | const vectorsFlag = "vectors" 18 | const jsonFlag = "json" 19 | const assertReachableFlag = "assert-reachable" 20 | const assertNotReachableFlag = "assert-not-reachable" 21 | 22 | var explain bool 23 | var showVectors bool 24 | var outputJSON bool 25 | var assertReachable bool 26 | var assertNotReachable bool 27 | 28 | var rootCmd = &cobra.Command{ 29 | Use: "reach", 30 | Short: "reach examines network reachability issues in AWS", 31 | Long: `reach examines network reachability issues in AWS 32 | See https://github.com/luhring/reach for documentation.`, 33 | Args: func(cmd *cobra.Command, args []string) error { 34 | if len(args) < 2 { 35 | return errors.New("requires at least two arguments") 36 | } 37 | 38 | if assertReachable && assertNotReachable { 39 | return errors.New("cannot assert both reachable and not reachable at the same time") 40 | } 41 | 42 | return nil 43 | }, 44 | Run: func(cmd *cobra.Command, args []string) { 45 | sourceIdentifier := args[0] 46 | destinationIdentifier := args[1] 47 | 48 | var provider aws.ResourceProvider = api.NewResourceProvider() 49 | 50 | source, err := aws.NewSubject(sourceIdentifier, provider) 51 | if err != nil { 52 | exitWithError(err) 53 | } 54 | source.SetRoleToSource() 55 | 56 | destination, err := aws.NewSubject(destinationIdentifier, provider) 57 | if err != nil { 58 | exitWithError(err) 59 | } 60 | destination.SetRoleToDestination() 61 | 62 | if !outputJSON && !explain && !showVectors { 63 | fmt.Printf("source: %s\ndestination: %s\n\n", source.ID, destination.ID) 64 | } 65 | 66 | a := analyzer.New() 67 | analysis, err := a.Analyze(source, destination) 68 | if err != nil { 69 | exitWithError(err) 70 | } 71 | 72 | mergedTraffic, err := analysis.MergedTraffic() 73 | if err != nil { 74 | exitWithError(err) 75 | } 76 | 77 | if outputJSON { 78 | fmt.Println(analysis.ToJSON()) 79 | } else if explain { 80 | ex := explainer.New(*analysis) 81 | fmt.Print(ex.Explain()) 82 | } else if showVectors { 83 | var vectorOutputs []string 84 | 85 | for _, v := range analysis.NetworkVectors { 86 | vectorOutputs = append(vectorOutputs, v.String()) 87 | } 88 | 89 | fmt.Print(strings.Join(vectorOutputs, "\n")) 90 | } else { 91 | fmt.Print("network traffic allowed from source to destination:" + "\n") 92 | fmt.Print(mergedTraffic.ColorStringWithSymbols()) 93 | 94 | if len(analysis.NetworkVectors) > 1 { // handling this case with care; this view isn't optimized for multi-vector output! 95 | printMergedResultsWarning() 96 | warnIfAnyVectorHasRestrictedReturnTraffic(analysis.NetworkVectors) 97 | } else { 98 | // calculate merged return traffic 99 | mergedReturnTraffic, err := analysis.MergedReturnTraffic() 100 | if err != nil { 101 | exitWithError(err) 102 | } 103 | 104 | restrictedProtocols := mergedTraffic.ProtocolsWithRestrictedReturnPath(mergedReturnTraffic) 105 | if len(restrictedProtocols) > 0 { 106 | found, warnings := explainer.WarningsFromRestrictedReturnPath(restrictedProtocols) 107 | if found { 108 | fmt.Print("\n" + warnings + "\n") 109 | } 110 | } 111 | } 112 | } 113 | 114 | if assertReachable { 115 | doAssertReachable(*analysis) 116 | } 117 | 118 | if assertNotReachable { 119 | doAssertNotReachable(*analysis) 120 | } 121 | }, 122 | } 123 | 124 | // Execute runs the root command 125 | func Execute() { 126 | if err := rootCmd.Execute(); err != nil { 127 | exitWithError(err) 128 | } 129 | } 130 | 131 | func init() { 132 | rootCmd.Flags().BoolVar(&explain, explainFlag, false, "explain how the configuration was analyzed") 133 | rootCmd.Flags().BoolVar(&showVectors, vectorsFlag, false, "show allowed traffic in terms of network vectors") 134 | rootCmd.Flags().BoolVar(&outputJSON, jsonFlag, false, "output full analysis as JSON (overrides other display flags)") 135 | rootCmd.Flags().BoolVar(&assertReachable, assertReachableFlag, false, "exit non-zero if no traffic is allowed from source to destination") 136 | rootCmd.Flags().BoolVar(&assertNotReachable, assertNotReachableFlag, false, "exit non-zero if any traffic can reach destination from source") 137 | } 138 | -------------------------------------------------------------------------------- /cmd/warnings.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/luhring/reach/reach" 8 | ) 9 | 10 | func printMergedResultsWarning() { 11 | const mergedResultsWarning = "WARNING: Reach detected more than one network path between the source and destination. Reach calls these paths \"network vectors\". The analysis result shown above is the merging of all network vectors' analysis results. The impact that infrastructure configuration has on actual network reachability might vary based on the way hosts are configured to use their network interfaces, and Reach is unable to access any configuration internal to a host. To see the network reachability across individual network vectors, run the command again with '--" + vectorsFlag + "'.\n" 12 | _, _ = fmt.Fprint(os.Stderr, "\n"+mergedResultsWarning) 13 | } 14 | 15 | func warnIfAnyVectorHasRestrictedReturnTraffic(vectors []reach.NetworkVector) { 16 | for _, v := range vectors { 17 | if !v.ReturnTraffic.All() { 18 | const restrictedVectorReturnTraffic = "WARNING: One or more of the analyzed network vectors has restrictions on network traffic allowed to return from the destination to the source. For details, run the command again with '--" + vectorsFlag + "'.\n" 19 | _, _ = fmt.Fprintf(os.Stderr, "\n"+restrictedVectorReturnTraffic) 20 | 21 | return 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/luhring/reach 2 | 3 | require ( 4 | github.com/aws/aws-sdk-go v1.33.0 5 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 6 | github.com/mattn/go-colorable v0.1.1 // indirect 7 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b 8 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d 9 | github.com/spf13/cobra v0.0.3 10 | github.com/spf13/pflag v1.0.3 // indirect 11 | golang.org/x/sys v0.0.0-20191018095205-727590c5006e 12 | ) 13 | 14 | go 1.13 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.33.0 h1:Bq5Y6VTLbfnJp1IV8EL/qUU5qO1DYHda/zis/sqevkY= 2 | github.com/aws/aws-sdk-go v1.33.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 6 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 7 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 8 | github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= 9 | github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= 10 | github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= 11 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 12 | github.com/mattn/go-isatty v0.0.5 h1:tHXDdz1cpzGaovsTB+TVB8q90WEokoVmfMqoVcrLUgw= 13 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 14 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 15 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 16 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 17 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 22 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 23 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 24 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 27 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 28 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 29 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= 30 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 31 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 32 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 33 | golang.org/x/sys v0.0.0-20191018095205-727590c5006e h1:ZtoklVMHQy6BFRHkbG6JzK+S6rX82//Yeok1vMlizfQ= 34 | golang.org/x/sys v0.0.0-20191018095205-727590c5006e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 40 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 41 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/luhring/reach/cmd" 7 | ) 8 | 9 | func main() { 10 | cmd.Execute() 11 | } 12 | -------------------------------------------------------------------------------- /main_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "golang.org/x/sys/windows" 9 | 10 | "github.com/luhring/reach/cmd" 11 | ) 12 | 13 | func main() { 14 | var originalMode uint32 15 | stdout := windows.Handle(os.Stdout.Fd()) 16 | 17 | _ = windows.GetConsoleMode(stdout, &originalMode) 18 | _ = windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) 19 | defer windows.SetConsoleMode(stdout, originalMode) 20 | 21 | cmd.Execute() 22 | } 23 | -------------------------------------------------------------------------------- /reach/acceptance/acceptance.go: -------------------------------------------------------------------------------- 1 | package acceptance 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | var acceptance = flag.Bool("acceptance", false, "perform full acceptance testing") 10 | 11 | // Check to see if the acceptance flag was set, and if not, skip the current test. 12 | func Check(t *testing.T) { 13 | t.Helper() 14 | 15 | if !*acceptance { 16 | t.Skip("not running acceptance tests") 17 | } 18 | } 19 | 20 | // IfErrorFailNow determines if the err value contains an error, and if so, calls t.Fatal(err). 21 | func IfErrorFailNow(t *testing.T, err error) { 22 | t.Helper() 23 | 24 | if err != nil { 25 | fmt.Print("\n\nFAILING NOW!\n\nWriting error to test log...\n\n") 26 | t.Fatal(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/golden/analysis_subjects_two_ec2_instances.json: -------------------------------------------------------------------------------- 1 | { 2 | "subjects": [ 3 | { 4 | "kind": "ec2Instance", 5 | "properties": { 6 | "id": "{{.SourceEC2InstanceID}}" 7 | }, 8 | "role": "source" 9 | }, 10 | { 11 | "kind": "ec2Instance", 12 | "properties": { 13 | "id": "{{.DestinationEC2InstanceID}}" 14 | }, 15 | "role": "destination" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ami_ubuntu.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "ubuntu" { 2 | most_recent = true 3 | 4 | filter { 5 | name = "name" 6 | values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-*"] 7 | } 8 | 9 | filter { 10 | name = "virtualization-type" 11 | values = ["hvm"] 12 | } 13 | 14 | owners = ["099720109477"] # Canonical 15 | } 16 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_all_traffic.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.outbound_allow_all.id 9 | ] 10 | 11 | tags = { 12 | Name = "aat_source" 13 | } 14 | } 15 | 16 | resource "aws_instance" "destination" { 17 | ami = data.aws_ami.ubuntu.id 18 | instance_type = "t2.micro" 19 | subnet_id = aws_subnet.subnet_1_of_1.id 20 | 21 | vpc_security_group_ids = [ 22 | aws_security_group.inbound_allow_all.id 23 | ] 24 | 25 | tags = { 26 | Name = "aat_destination" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_https_via_two-way_sg_ip_match.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.outbound_allow_https_to_ip.id 9 | ] 10 | 11 | tags = { 12 | Name = "aat_source" 13 | } 14 | } 15 | 16 | resource "aws_instance" "destination" { 17 | ami = data.aws_ami.ubuntu.id 18 | instance_type = "t2.micro" 19 | subnet_id = aws_subnet.subnet_1_of_1.id 20 | 21 | vpc_security_group_ids = [ 22 | aws_security_group.inbound_allow_https_from_ip.id 23 | ] 24 | 25 | tags = { 26 | Name = "aat_destination" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_multiple_protocols.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.no_rules.id, 9 | aws_security_group.outbound_allow_all_tcp.id, 10 | aws_security_group.outbound_allow_all_udp_to_sg_no_rules.id, 11 | aws_security_group.outbound_allow_esp.id, 12 | ] 13 | 14 | tags = { 15 | Name = "aat_source" 16 | } 17 | } 18 | 19 | resource "aws_instance" "destination" { 20 | ami = data.aws_ami.ubuntu.id 21 | instance_type = "t2.micro" 22 | subnet_id = aws_subnet.subnet_1_of_1.id 23 | 24 | vpc_security_group_ids = [ 25 | aws_security_group.no_rules.id, 26 | aws_security_group.inbound_allow_udp_dns_from_sg_no_rules.id, 27 | aws_security_group.inbound_allow_ssh_from_all_ip_addresses.id, 28 | aws_security_group.inbound_allow_esp.id, 29 | ] 30 | 31 | tags = { 32 | Name = "aat_destination" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_no_security_group_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | vpc_security_group_ids = [ 7 | aws_security_group.no_rules.id 8 | ] 9 | 10 | tags = { 11 | Name = "aat_source" 12 | } 13 | } 14 | 15 | resource "aws_instance" "destination" { 16 | ami = data.aws_ami.ubuntu.id 17 | instance_type = "t2.micro" 18 | subnet_id = aws_subnet.subnet_1_of_1.id 19 | 20 | vpc_security_group_ids = [ 21 | aws_security_group.no_rules.id 22 | ] 23 | 24 | tags = { 25 | Name = "aat_destination" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_ssh.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.outbound_allow_all.id 9 | ] 10 | 11 | tags = { 12 | Name = "aat_source" 13 | } 14 | } 15 | 16 | resource "aws_instance" "destination" { 17 | ami = data.aws_ami.ubuntu.id 18 | instance_type = "t2.micro" 19 | subnet_id = aws_subnet.subnet_1_of_1.id 20 | 21 | vpc_security_group_ids = [ 22 | aws_security_group.inbound_allow_ssh_from_all_ip_addresses.id 23 | ] 24 | 25 | tags = { 26 | Name = "aat_destination" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_subnet_udp_dns_via_sg_reference.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_1.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.no_rules.id, 9 | aws_security_group.outbound_allow_all_udp_to_sg_no_rules.id 10 | ] 11 | 12 | tags = { 13 | Name = "aat_source" 14 | } 15 | } 16 | 17 | resource "aws_instance" "destination" { 18 | ami = data.aws_ami.ubuntu.id 19 | instance_type = "t2.micro" 20 | subnet_id = aws_subnet.subnet_1_of_1.id 21 | 22 | vpc_security_group_ids = [ 23 | aws_security_group.no_rules.id, 24 | aws_security_group.inbound_allow_udp_dns_from_sg_no_rules.id 25 | ] 26 | 27 | tags = { 28 | Name = "aat_destination" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_vpc_all_esp.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_2.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.outbound_allow_esp.id 9 | ] 10 | 11 | tags = { 12 | Name = "aat_source" 13 | } 14 | } 15 | 16 | resource "aws_instance" "destination" { 17 | ami = data.aws_ami.ubuntu.id 18 | instance_type = "t2.micro" 19 | subnet_id = aws_subnet.subnet_2_of_2.id 20 | 21 | vpc_security_group_ids = [ 22 | aws_security_group.inbound_allow_all.id 23 | ] 24 | 25 | tags = { 26 | Name = "aat_destination" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_vpc_all_traffic.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_2.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.outbound_allow_all.id 9 | ] 10 | 11 | tags = { 12 | Name = "aat_source" 13 | } 14 | } 15 | 16 | resource "aws_instance" "destination" { 17 | ami = data.aws_ami.ubuntu.id 18 | instance_type = "t2.micro" 19 | subnet_id = aws_subnet.subnet_2_of_2.id 20 | 21 | vpc_security_group_ids = [ 22 | aws_security_group.inbound_allow_all.id 23 | ] 24 | 25 | tags = { 26 | Name = "aat_destination" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/ec2_instances_same_vpc_postgres.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "source" { 2 | ami = data.aws_ami.ubuntu.id 3 | instance_type = "t2.micro" 4 | subnet_id = aws_subnet.subnet_1_of_2.id 5 | 6 | 7 | vpc_security_group_ids = [ 8 | aws_security_group.no_rules.id, 9 | aws_security_group.outbound_allow_postgres_to_sg_no_rules.id 10 | ] 11 | 12 | tags = { 13 | Name = "aat_source" 14 | } 15 | } 16 | 17 | resource "aws_instance" "destination" { 18 | ami = data.aws_ami.ubuntu.id 19 | instance_type = "t2.micro" 20 | subnet_id = aws_subnet.subnet_2_of_2.id 21 | 22 | vpc_security_group_ids = [ 23 | aws_security_group.no_rules.id, 24 | aws_security_group.inbound_allow_postgres_from_sg_no_rules.id 25 | ] 26 | 27 | tags = { 28 | Name = "aat_destination" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/network_acl_both_subnets_all_tcp.tf: -------------------------------------------------------------------------------- 1 | resource "aws_network_acl" "both_subnets_all_tcp" { 2 | vpc_id = "${aws_vpc.aat_vpc.id}" 3 | 4 | subnet_ids = [ 5 | aws_subnet.subnet_1_of_2.id, 6 | aws_subnet.subnet_2_of_2.id, 7 | ] 8 | 9 | egress { 10 | protocol = "tcp" 11 | rule_no = 100 12 | action = "allow" 13 | cidr_block = "0.0.0.0/0" 14 | from_port = 0 15 | to_port = 65535 16 | } 17 | 18 | ingress { 19 | protocol = "tcp" 20 | rule_no = 100 21 | action = "allow" 22 | cidr_block = "0.0.0.0/0" 23 | from_port = 0 24 | to_port = 65535 25 | } 26 | 27 | tags = { 28 | Name = "aat_both_subnets_all_tcp" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/network_acl_both_subnets_all_traffic.tf: -------------------------------------------------------------------------------- 1 | resource "aws_network_acl" "both_subnets_all_traffic" { 2 | vpc_id = "${aws_vpc.aat_vpc.id}" 3 | 4 | subnet_ids = [ 5 | aws_subnet.subnet_1_of_2.id, 6 | aws_subnet.subnet_2_of_2.id, 7 | ] 8 | 9 | egress { 10 | protocol = "-1" 11 | rule_no = 100 12 | action = "allow" 13 | cidr_block = "0.0.0.0/0" 14 | from_port = 0 15 | to_port = 0 16 | } 17 | 18 | ingress { 19 | protocol = "-1" 20 | rule_no = 100 21 | action = "allow" 22 | cidr_block = "0.0.0.0/0" 23 | from_port = 0 24 | to_port = 0 25 | } 26 | 27 | tags = { 28 | Name = "aat_both_subnets_all_traffic" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/network_acl_both_subnets_no_traffic.tf: -------------------------------------------------------------------------------- 1 | resource "aws_network_acl" "both_subnets_no_traffic" { 2 | vpc_id = "${aws_vpc.aat_vpc.id}" 3 | 4 | subnet_ids = [ 5 | aws_subnet.subnet_1_of_2.id, 6 | aws_subnet.subnet_2_of_2.id, 7 | ] 8 | 9 | tags = { 10 | Name = "aat_both_subnets_no_traffic" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/network_acl_destination_subnet_tightened_postgres.tf: -------------------------------------------------------------------------------- 1 | resource "aws_network_acl" "destination_subnet_tightened_postgres" { 2 | vpc_id = "${aws_vpc.aat_vpc.id}" 3 | 4 | subnet_ids = [ 5 | aws_subnet.subnet_2_of_2.id, 6 | ] 7 | 8 | egress { 9 | protocol = "tcp" 10 | rule_no = 100 11 | action = "allow" 12 | cidr_block = aws_subnet.subnet_1_of_2.cidr_block 13 | from_port = 0 14 | to_port = 65535 15 | } 16 | 17 | ingress { 18 | protocol = "tcp" 19 | rule_no = 100 20 | action = "allow" 21 | cidr_block = aws_subnet.subnet_1_of_2.cidr_block 22 | from_port = 5432 23 | to_port = 5432 24 | } 25 | 26 | tags = { 27 | Name = "aat_destination_subnet_tightened_postgres" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/network_acl_source_subnet_tightened_postgres.tf: -------------------------------------------------------------------------------- 1 | resource "aws_network_acl" "source_subnet_tightened_postgres" { 2 | vpc_id = "${aws_vpc.aat_vpc.id}" 3 | 4 | subnet_ids = [ 5 | aws_subnet.subnet_1_of_2.id, 6 | ] 7 | 8 | egress { 9 | protocol = "tcp" 10 | rule_no = 100 11 | action = "allow" 12 | cidr_block = aws_subnet.subnet_2_of_2.cidr_block 13 | from_port = 5432 14 | to_port = 5432 15 | } 16 | 17 | ingress { 18 | protocol = "tcp" 19 | rule_no = 100 20 | action = "allow" 21 | cidr_block = aws_subnet.subnet_2_of_2.cidr_block 22 | from_port = 0 23 | to_port = 65535 24 | } 25 | 26 | tags = { 27 | Name = "aat_source_subnet_tightened_postgres" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/outputs.tf: -------------------------------------------------------------------------------- 1 | output "source_id" { 2 | value = aws_instance.source.id 3 | } 4 | 5 | output "destination_id" { 6 | value = aws_instance.destination.id 7 | } 8 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_all.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_all" { 2 | name = "aat_inbound_allow_all" 3 | description = "Allow all inbound traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 0 8 | to_port = 0 9 | protocol = "-1" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_esp.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_esp" { 2 | name = "aat_inbound_allow_esp" 3 | description = "Allow all inbound ESP traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 0 8 | to_port = 0 9 | protocol = "50" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_https_from_ip.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_https_from_ip" { 2 | name = "aat_inbound_allow_https_from_ip" 3 | description = "Allow inbound HTTPS traffic from IP CIDR" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 443 8 | to_port = 443 9 | protocol = "tcp" 10 | cidr_blocks = [aws_subnet.subnet_1_of_1.cidr_block] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_postgres_from_sg_no_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_postgres_from_sg_no_rules" { 2 | name = "aat_inbound_allow_postgres_from_sg_no_rules" 3 | description = "Allow all inbound Postgres traffic from SG no rules" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 5432 8 | to_port = 5432 9 | protocol = "tcp" 10 | security_groups = [aws_security_group.no_rules.id] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_ssh.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_ssh_from_all_ip_addresses" { 2 | name = "aat_inbound_allow_ssh_from_all_ip_addresses" 3 | description = "Allow all SSH traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 22 8 | to_port = 22 9 | protocol = "tcp" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_inbound_allow_udp_dns_from_sg_no_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "inbound_allow_udp_dns_from_sg_no_rules" { 2 | name = "aat_inbound_allow_udp_dns_from_sg_no_rules" 3 | description = "Allow DNS (UDP) from SG no rules" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | ingress { 7 | from_port = 53 8 | to_port = 53 9 | protocol = "udp" 10 | security_groups = [aws_security_group.no_rules.id] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_no_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "no_rules" { 2 | name = "aat_no_rules" 3 | description = "No rules associated with this group" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | } 6 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_all.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_all" { 2 | name = "aat_outbound_allow_all" 3 | description = "Allow all outbound traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 0 8 | to_port = 0 9 | protocol = "-1" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_all_tcp.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_all_tcp" { 2 | name = "aat_outbound_allow_all_tcp" 3 | description = "Allow all outbound TCP traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 0 8 | to_port = 65535 9 | protocol = "tcp" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_all_udp_to_sg_no_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_all_udp_to_sg_no_rules" { 2 | name = "aat_outbound_allow_all_udp_to_sg_no_rules" 3 | description = "Allow all outbound UDP traffic to SG no rules" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 0 8 | to_port = 65535 9 | protocol = "udp" 10 | security_groups = [aws_security_group.no_rules.id] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_esp.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_esp" { 2 | name = "aat_outbound_allow_esp" 3 | description = "Allow all outbound ESP traffic" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 0 8 | to_port = 0 9 | protocol = "50" 10 | cidr_blocks = ["0.0.0.0/0"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_https_to_ip.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_https_to_ip" { 2 | name = "aat_outbound_allow_https_to_ip" 3 | description = "Allow outbound HTTPS traffic to IP CIDR" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 443 8 | to_port = 443 9 | protocol = "tcp" 10 | cidr_blocks = [aws_subnet.subnet_1_of_1.cidr_block] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/security_group_outbound_allow_postgres_to_sg_no_rules.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "outbound_allow_postgres_to_sg_no_rules" { 2 | name = "aat_outbound_allow_postgres_to_sg_no_rules" 3 | description = "Allow all outbound Postgres traffic to SG no rules" 4 | vpc_id = aws_vpc.aat_vpc.id 5 | 6 | egress { 7 | from_port = 5432 8 | to_port = 5432 9 | protocol = "tcp" 10 | security_groups = [aws_security_group.no_rules.id] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/subnet_pair.tf: -------------------------------------------------------------------------------- 1 | resource "aws_subnet" "subnet_1_of_2" { 2 | vpc_id = aws_vpc.aat_vpc.id 3 | cidr_block = "10.0.1.0/24" 4 | map_public_ip_on_launch = false 5 | 6 | tags = { 7 | Name = "aat_subnet_1_of_2" 8 | } 9 | } 10 | 11 | resource "aws_subnet" "subnet_2_of_2" { 12 | vpc_id = aws_vpc.aat_vpc.id 13 | cidr_block = "10.0.2.0/24" 14 | map_public_ip_on_launch = false 15 | 16 | tags = { 17 | Name = "aat_subnet_2_of_2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/subnet_single.tf: -------------------------------------------------------------------------------- 1 | resource "aws_subnet" "subnet_1_of_1" { 2 | vpc_id = aws_vpc.aat_vpc.id 3 | cidr_block = "10.0.1.0/24" 4 | map_public_ip_on_launch = false 5 | 6 | tags = { 7 | Name = "aat_subnet_1_of_1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /reach/acceptance/data/tf/vpc.tf: -------------------------------------------------------------------------------- 1 | resource "aws_vpc" "aat_vpc" { 2 | cidr_block = "10.0.0.0/16" 3 | 4 | tags = { 5 | Name = "aat_vpc" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /reach/acceptance/template.go: -------------------------------------------------------------------------------- 1 | package acceptance 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "path" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | type subjectPairForTwoEC2Instances struct { 13 | SourceEC2InstanceID string 14 | DestinationEC2InstanceID string 15 | } 16 | 17 | // processTemplate handles injecting data into specified template file. 18 | func processTemplate(t *testing.T, name string, data interface{}) (string, error) { 19 | t.Helper() 20 | 21 | tmpl, err := template.New(name).ParseFiles(path.Join("..", "acceptance", "data", "golden", name)) // TODO: Need a better way to coordinate path construction with CWD of test 22 | if err != nil { 23 | t.Fail() 24 | return "", fmt.Errorf("error: unable to parse template file '%s': %v", name, err) 25 | } 26 | 27 | var b bytes.Buffer 28 | err = tmpl.Execute(&b, data) 29 | if err != nil { 30 | return "", fmt.Errorf("unable to execute template: %v", err) 31 | } 32 | 33 | return strings.TrimSpace(b.String()), nil 34 | } 35 | 36 | // ProcessTemplateForSubjectPairForTwoEC2Instances calls processTemplate using the subjectPairForTwoEC2Instances data type. 37 | func ProcessTemplateForSubjectPairForTwoEC2Instances(t *testing.T, name, sourceID, destinationID string) (string, error) { 38 | t.Helper() 39 | 40 | return processTemplate(t, name, &subjectPairForTwoEC2Instances{ 41 | SourceEC2InstanceID: sourceID, 42 | DestinationEC2InstanceID: destinationID, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /reach/acceptance/terraform/terraform.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "strings" 14 | "testing" 15 | ) 16 | 17 | var logTF = flag.Bool("log-tf", false, "log output of Terraform helper") 18 | 19 | const execName = "terraform" 20 | const terraformPlan = "terraform.plan" 21 | 22 | // Terraform is a usable, programmatic representation of the Terraform application. 23 | type Terraform struct { 24 | t *testing.T 25 | execPath string 26 | tempDir string 27 | logging bool 28 | } 29 | 30 | // New creates a new instance of a Terraform struct that can perform the operations of a basic Terraform workflow. 31 | // Callers should call CleanUp when done with the object to ensure there are no lingering side effects. 32 | func New(t *testing.T) (*Terraform, error) { 33 | t.Helper() 34 | 35 | logging := *logTF 36 | 37 | execPath, err := findExecutable(logging) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | tempDir, err := createTempDir(logging) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return &Terraform{ 48 | t: t, 49 | execPath: execPath, 50 | tempDir: tempDir, 51 | logging: logging, 52 | }, nil 53 | } 54 | 55 | // CleanUp removes the temporary directory set up for Terraform assets and all contents. 56 | func (tf *Terraform) CleanUp() error { 57 | tf.t.Helper() 58 | 59 | if tf.logging { 60 | log.Println("cleaning up...") 61 | } 62 | 63 | err := os.RemoveAll(tf.tempDir) 64 | if err != nil { 65 | return fmt.Errorf("unable to clean up temp dir '%s': %v", tf.tempDir, err) 66 | } 67 | 68 | if tf.logging { 69 | log.Printf("temp dir was successfully removed ('%s')", tf.tempDir) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // LoadFilesFromDir calls LoadFile for all specified files within the specified directory. 76 | func (tf *Terraform) LoadFilesFromDir(dir string, files ...string) error { 77 | for _, file := range files { 78 | fullPath := path.Join(dir, file) 79 | err := tf.LoadFile(fullPath) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // LoadFile copies a file for Terraform to use into the temporary working directory. 89 | func (tf *Terraform) LoadFile(file string) error { 90 | tf.t.Helper() 91 | 92 | if tf.logging { 93 | log.Printf("loading '%s'...", file) 94 | } 95 | 96 | data, err := ioutil.ReadFile(file) 97 | if err != nil { 98 | return fmt.Errorf("unable to read file '%s': %v", file, err) 99 | } 100 | 101 | _, filename := path.Split(file) 102 | destFile := path.Join(tf.tempDir, filename) 103 | err = ioutil.WriteFile(destFile, data, 0644) 104 | if err != nil { 105 | return fmt.Errorf("unable to write file '%s': %v", destFile, err) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // Init calls 'Terraform init' to initialize Terraform in the temporary working directory. 112 | func (tf *Terraform) Init() error { 113 | tf.t.Helper() 114 | 115 | if tf.logging { 116 | log.Print("initializing Terraform...") 117 | } 118 | 119 | err := tf.action("unable to initialize Terraform", "init") 120 | if err != nil { 121 | return err 122 | } 123 | 124 | return nil 125 | } 126 | 127 | // Plan calls 'Terraform plan' to create a Terraform plan that can be run by calling Apply. 128 | func (tf *Terraform) Plan() error { 129 | tf.t.Helper() 130 | 131 | if tf.logging { 132 | log.Print("creating a Terraform plan...") 133 | } 134 | 135 | err := tf.action("unable to create plan", "plan", "-out", terraformPlan) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | return nil 141 | } 142 | 143 | // Apply calls 'Terraform apply', which applies the plan generated by calling Plan. This means you must first call Plan. 144 | func (tf *Terraform) Apply() error { 145 | tf.t.Helper() 146 | 147 | if tf.logging { 148 | log.Print("deploying resources from Terraform plan...") 149 | } 150 | 151 | err := tf.action("unable to apply plan", "apply", terraformPlan) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | return nil 157 | } 158 | 159 | // PlanAndApply does the equivalent of calling Plan and Apply. 160 | func (tf *Terraform) PlanAndApply() error { 161 | tf.t.Helper() 162 | 163 | err := tf.Plan() 164 | if err != nil { 165 | return err 166 | } 167 | 168 | err = tf.Apply() 169 | if err != nil { 170 | return err 171 | } 172 | 173 | return nil 174 | } 175 | 176 | // Destroy calls 'Terraform destroy' non-interactively, which tears down all resources referenced within the temp dir. 177 | func (tf *Terraform) Destroy() error { 178 | tf.t.Helper() 179 | 180 | if tf.logging { 181 | log.Print("destroying resources...") 182 | } 183 | 184 | err := tf.action("unable to destroy", "destroy", "-auto-approve") 185 | if err != nil { 186 | return err 187 | } 188 | 189 | return nil 190 | } 191 | 192 | // Output calls 'Terraform output' to retrieve a predefined output value from available resources. 193 | func (tf *Terraform) Output(name string) (string, error) { 194 | tf.t.Helper() 195 | 196 | pop, err := tf.changeToTempDir() 197 | defer func() { 198 | err := pop() 199 | if err != nil { 200 | // Report error irrespective of logTF setting for visibility 201 | fmt.Printf("encountered error in deferred call: %v", err) 202 | } 203 | }() 204 | 205 | output, err := tf.execForOutput("output", "-no-color", name) 206 | if err != nil { 207 | return "", fmt.Errorf("unable to retrieve output '%s': %v", name, err) 208 | } 209 | 210 | value := strings.TrimSpace(output) 211 | 212 | if tf.logging { 213 | log.Printf("retrieved output value: %s=%s", name, value) 214 | } 215 | 216 | return value, nil 217 | } 218 | 219 | // Version retrieves the current Terraform version by calling 'Terraform version'. 220 | func (tf *Terraform) Version() (string, error) { 221 | tf.t.Helper() 222 | 223 | b, err := tf.execForOutput("version") 224 | if err != nil { 225 | return "", fmt.Errorf("unable to get version: %v", err) 226 | } 227 | 228 | return b, nil 229 | } 230 | 231 | func (tf *Terraform) action(errMessage string, args ...string) error { 232 | tf.t.Helper() 233 | 234 | pop, err := tf.changeToTempDir() 235 | defer func() { 236 | err := pop() 237 | if err != nil { 238 | // Report error irrespective of logTF setting for visibility 239 | fmt.Printf("encountered error in deferred call: %v", err) 240 | } 241 | }() 242 | 243 | err = tf.exec(args...) 244 | if err != nil { 245 | return fmt.Errorf("%s: %v", errMessage, err) 246 | } 247 | 248 | return nil 249 | } 250 | 251 | func (tf *Terraform) changeToTempDir() (func() error, error) { 252 | tf.t.Helper() 253 | 254 | originalWorkDir, err := os.Getwd() 255 | if err != nil { 256 | return nil, fmt.Errorf("unable to determine original working directory: %v", err) 257 | } 258 | 259 | err = os.Chdir(tf.tempDir) 260 | if err != nil { 261 | tf.t.Fatalf("unable to change directory to '%s': %v", tf.tempDir, err) 262 | } 263 | 264 | changeToOriginalDir := func() error { 265 | err = os.Chdir(originalWorkDir) 266 | if err != nil { 267 | return fmt.Errorf("unable to change directory to '%s': %v", originalWorkDir, err) 268 | } 269 | 270 | return nil 271 | } 272 | 273 | return changeToOriginalDir, nil 274 | } 275 | 276 | func (tf *Terraform) exec(args ...string) error { 277 | tf.t.Helper() 278 | 279 | cmd := exec.Command(tf.execPath, args...) 280 | 281 | var stdout, stderr io.ReadCloser 282 | 283 | if tf.logging { 284 | var err error 285 | stdout, err = cmd.StdoutPipe() 286 | if err != nil { 287 | return err 288 | } 289 | stderr, err = cmd.StderrPipe() 290 | if err != nil { 291 | return err 292 | } 293 | } 294 | 295 | err := cmd.Start() 296 | if err != nil { 297 | return err 298 | } 299 | 300 | if tf.logging { 301 | multi := io.MultiReader(stdout, stderr) 302 | in := bufio.NewScanner(multi) 303 | 304 | for in.Scan() { 305 | fmt.Printf("%v\n", in.Text()) 306 | } 307 | if err := in.Err(); err != nil { 308 | fmt.Printf("error: %s", err) 309 | } 310 | } 311 | 312 | err = cmd.Wait() 313 | if err != nil { 314 | return fmt.Errorf("command exited non-zero: %v", err) 315 | } 316 | 317 | return nil 318 | } 319 | 320 | func (tf *Terraform) execForOutput(args ...string) (string, error) { 321 | tf.t.Helper() 322 | 323 | cmd := exec.Command(tf.execPath, args...) 324 | 325 | stdout, err := cmd.StdoutPipe() 326 | if err != nil { 327 | return "", fmt.Errorf("unable to connect to stdout: %v", err) 328 | } 329 | stderr, err := cmd.StderrPipe() 330 | if err != nil { 331 | return "", fmt.Errorf("unable to connect to stderr: %v", err) 332 | } 333 | 334 | err = cmd.Start() 335 | if err != nil { 336 | return "", fmt.Errorf("unable to start command: %v", err) 337 | } 338 | 339 | fromStdout := bufio.NewScanner(stdout) 340 | fromStderr := bufio.NewScanner(stderr) 341 | 342 | var output string 343 | for fromStdout.Scan() { 344 | output += fmt.Sprintf("%s\n", fromStdout.Text()) 345 | } 346 | 347 | var errText string 348 | for fromStderr.Scan() { 349 | errText += fmt.Sprintf("%s\n", fromStderr.Text()) 350 | } 351 | 352 | err = cmd.Wait() 353 | if err != nil { 354 | return "", fmt.Errorf("command exited non-zero: %v\n\n%s", err, errText) 355 | } 356 | 357 | return output, nil 358 | } 359 | 360 | func findExecutable(logging bool) (string, error) { 361 | execPath, err := exec.LookPath(execName) 362 | if err != nil { 363 | return "", fmt.Errorf("unable to find %s: %v", execName, err) 364 | } 365 | 366 | if logging { 367 | log.Printf("found %s at: %s", execName, execPath) 368 | } 369 | 370 | return execPath, nil 371 | } 372 | 373 | func createTempDir(logging bool) (string, error) { 374 | // create temporary working directory for all Terraform operations 375 | tempDir, err := ioutil.TempDir("", "Terraform") 376 | if err != nil { 377 | return "", fmt.Errorf("unable to create temp dir: %v", err) 378 | } 379 | 380 | if logging { 381 | log.Printf("created temporary working directory for Terraform files: %s", tempDir) 382 | } 383 | 384 | return tempDir, nil 385 | } 386 | -------------------------------------------------------------------------------- /reach/analysis.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | ) 7 | 8 | // Analysis is the central structure of a Reach analysis. It describes what subjects were analyzed, what resources were retrieved, and a collection of network vectors between all source-to-destination pairings of subjects. 9 | type Analysis struct { 10 | Subjects []*Subject 11 | Resources *ResourceCollection 12 | NetworkVectors []NetworkVector 13 | } 14 | 15 | // NewAnalysis simply creates a new Analysis struct. 16 | func NewAnalysis(subjects []*Subject, resources *ResourceCollection, networkVectors []NetworkVector) *Analysis { 17 | return &Analysis{ 18 | Subjects: subjects, 19 | Resources: resources, 20 | NetworkVectors: networkVectors, 21 | } 22 | } 23 | 24 | // ToJSON outputs the Analysis as a JSON string. 25 | func (a *Analysis) ToJSON() string { 26 | b, err := json.MarshalIndent(a, "", " ") 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | return string(b) 31 | } 32 | 33 | // MergedTraffic gets the TrafficContent results of each of the analysis's network vectors and returns them as a merged TrafficContent. 34 | func (a *Analysis) MergedTraffic() (TrafficContent, error) { 35 | result := newTrafficContent() 36 | 37 | for _, v := range a.NetworkVectors { 38 | if t := v.Traffic; t != nil { 39 | mergedTrafficContent, err := result.Merge(*t) 40 | if err != nil { 41 | return TrafficContent{}, err 42 | } 43 | 44 | result = mergedTrafficContent 45 | } 46 | } 47 | 48 | return result, nil 49 | } 50 | 51 | // MergedReturnTraffic gets the return TrafficContent results of each of the analysis's network vectors and returns them as a merged TrafficContent. 52 | func (a *Analysis) MergedReturnTraffic() (TrafficContent, error) { 53 | result := newTrafficContent() 54 | 55 | for _, v := range a.NetworkVectors { 56 | if t := v.ReturnTraffic; t != nil { 57 | mergedTrafficContent, err := result.Merge(*t) 58 | if err != nil { 59 | return TrafficContent{}, err 60 | } 61 | 62 | result = mergedTrafficContent 63 | } 64 | } 65 | 66 | return result, nil 67 | } 68 | 69 | // PassesAssertReachable determines if the analysis implies the source can reach the destination over at least one protocol whose return path is unobstructed. 70 | func (a Analysis) PassesAssertReachable() bool { 71 | forwardTrafficCanReach := false 72 | 73 | // For each vector, see if there is an obstructed path 74 | for _, vector := range a.NetworkVectors { 75 | if !vector.Traffic.None() { 76 | forwardTrafficCanReach = true 77 | 78 | for _, p := range vector.Traffic.Protocols() { 79 | // is return path obstructed (at all) for this protocol? 80 | if protocolReturnTraffic := vector.ReturnTraffic.protocol(p); !protocolReturnTraffic.complete() { 81 | return false 82 | } 83 | } 84 | } 85 | } 86 | 87 | if !forwardTrafficCanReach { 88 | return false 89 | } 90 | 91 | return true 92 | } 93 | 94 | // PassesAssertNotReachable determines if the analysis implies the source has no way to send network traffic to the destination. 95 | func (a Analysis) PassesAssertNotReachable() bool { 96 | // Here, we want to be more careful / conservative. If any traffic can get out to destination, fail, regardless of return traffic. 97 | 98 | forwardTraffic, err := a.MergedTraffic() 99 | if err != nil { 100 | return false 101 | } 102 | 103 | if !forwardTraffic.None() { 104 | return false 105 | } 106 | 107 | return true 108 | } 109 | -------------------------------------------------------------------------------- /reach/analyzer/analyzer.go: -------------------------------------------------------------------------------- 1 | package analyzer 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/luhring/reach/reach" 8 | "github.com/luhring/reach/reach/aws" 9 | "github.com/luhring/reach/reach/aws/api" 10 | ) 11 | 12 | // Analyzer performs Reach's central network traffic analysis. 13 | type Analyzer struct { 14 | resourceCollection *reach.ResourceCollection 15 | } 16 | 17 | // New creates a new Analyzer that has a new resource collection. 18 | func New() *Analyzer { 19 | rc := reach.NewResourceCollection() 20 | return &Analyzer{ 21 | resourceCollection: rc, 22 | } 23 | } 24 | 25 | func (a *Analyzer) buildResourceCollection(subjects []*reach.Subject, provider aws.ResourceProvider) error { // TODO: Allow passing any number of providers of various domains 26 | for _, subject := range subjects { 27 | if subject.Role != reach.SubjectRoleNone { 28 | switch subject.Domain { 29 | case aws.ResourceDomainAWS: 30 | switch subject.Kind { 31 | case aws.SubjectKindEC2Instance: 32 | id := subject.ID 33 | 34 | ec2Instance, err := provider.EC2Instance(id) 35 | if err != nil { 36 | log.Fatalf("couldn't get resource: %v", err) 37 | } 38 | a.resourceCollection.Put(reach.ResourceReference{ 39 | Domain: aws.ResourceDomainAWS, 40 | Kind: aws.ResourceKindEC2Instance, 41 | ID: ec2Instance.ID, 42 | }, ec2Instance.ToResource()) 43 | 44 | dependencies, err := ec2Instance.Dependencies(provider) 45 | if err != nil { 46 | return err 47 | } 48 | a.resourceCollection.Merge(dependencies) 49 | default: 50 | return fmt.Errorf("unsupported subject kind: '%s'", subject.Kind) 51 | } 52 | default: 53 | return fmt.Errorf("unsupported subject domain: '%s'", subject.Domain) 54 | } 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Analyze performs a full analysis of allowed network traffic among the specified subjects. 62 | func (a *Analyzer) Analyze(subjects ...*reach.Subject) (*reach.Analysis, error) { 63 | // TODO: Eventually, this dependency wiring should depend on a passed in config. 64 | var provider aws.ResourceProvider = api.NewResourceProvider() 65 | 66 | err := a.buildResourceCollection(subjects, provider) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // TODO: Eventually, this dependency wiring should depend on a passed in config. 72 | var vectorDiscoverer reach.VectorDiscoverer = aws.NewVectorDiscoverer(a.resourceCollection) 73 | 74 | networkVectors, err := vectorDiscoverer.Discover(subjects) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | processedNetworkVectors := make([]reach.NetworkVector, len(networkVectors)) 80 | 81 | // TODO: Eventually, this dependency wiring should depend on a passed in config. 82 | var vectorAnalyzer reach.VectorAnalyzer = aws.NewVectorAnalyzer(a.resourceCollection) 83 | 84 | for i, v := range networkVectors { 85 | factors, processedVector, err := vectorAnalyzer.Factors(v) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | trafficContents := reach.TrafficContentsFromFactors(factors) 91 | trafficContent, err := reach.NewTrafficContentFromIntersectingMultiple(trafficContents) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | returnTrafficContents := reach.ReturnTrafficContentsFromFactors(factors) 97 | returnTrafficContent, err := reach.NewTrafficContentFromIntersectingMultiple(returnTrafficContents) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | processedVector.Traffic = &trafficContent 103 | processedVector.ReturnTraffic = &returnTrafficContent 104 | 105 | processedNetworkVectors[i] = processedVector 106 | } 107 | 108 | return reach.NewAnalysis(subjects, a.resourceCollection, processedNetworkVectors), nil 109 | } 110 | -------------------------------------------------------------------------------- /reach/analyzer/analyzer_test.go: -------------------------------------------------------------------------------- 1 | package analyzer 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/luhring/reach/reach" 8 | "github.com/luhring/reach/reach/acceptance" 9 | "github.com/luhring/reach/reach/acceptance/terraform" 10 | "github.com/luhring/reach/reach/aws" 11 | "github.com/luhring/reach/reach/set" 12 | ) 13 | 14 | func TestAnalyze(t *testing.T) { 15 | acceptance.Check(t) 16 | 17 | type testCase struct { 18 | name string 19 | files []string 20 | expectedForwardTraffic reach.TrafficContent 21 | expectedReturnTraffic reach.TrafficContent 22 | } 23 | 24 | groupings := []struct { 25 | name string 26 | files []string 27 | cases []testCase 28 | }{ 29 | { 30 | "same subnet", 31 | []string{ 32 | "main.tf", 33 | "outputs.tf", 34 | "ami_ubuntu.tf", 35 | "vpc.tf", 36 | "subnet_single.tf", 37 | }, 38 | []testCase{ 39 | { 40 | "no security group rules", 41 | []string{ 42 | "ec2_instances_same_subnet_no_security_group_rules.tf", 43 | "security_group_no_rules.tf", 44 | }, 45 | reach.NewTrafficContentForNoTraffic(), 46 | reach.NewTrafficContentForAllTraffic(), 47 | }, 48 | { 49 | "multiple protocols", 50 | []string{ 51 | "ec2_instances_same_subnet_multiple_protocols.tf", 52 | "security_group_no_rules.tf", 53 | "security_group_outbound_allow_all_udp_to_sg_no_rules.tf", 54 | "security_group_outbound_allow_esp.tf", 55 | "security_group_outbound_allow_all_tcp.tf", 56 | "security_group_inbound_allow_udp_dns_from_sg_no_rules.tf", 57 | "security_group_inbound_allow_esp.tf", 58 | "security_group_inbound_allow_ssh.tf", 59 | }, 60 | trafficAssorted(), 61 | reach.NewTrafficContentForAllTraffic(), 62 | }, 63 | { 64 | "UDP DNS via SG reference", 65 | []string{ 66 | "ec2_instances_same_subnet_udp_dns_via_sg_reference.tf", 67 | "security_group_no_rules.tf", 68 | "security_group_outbound_allow_all_udp_to_sg_no_rules.tf", 69 | "security_group_inbound_allow_udp_dns_from_sg_no_rules.tf", 70 | }, 71 | trafficDNS(), 72 | reach.NewTrafficContentForAllTraffic(), 73 | }, 74 | { 75 | "HTTPS via two-way IP match", 76 | []string{ 77 | "ec2_instances_same_subnet_https_via_two-way_sg_ip_match.tf", 78 | "security_group_outbound_allow_https_to_ip.tf", 79 | "security_group_inbound_allow_https_from_ip.tf", 80 | }, 81 | trafficHTTPS(), 82 | reach.NewTrafficContentForAllTraffic(), 83 | }, 84 | { 85 | "SSH", 86 | []string{ 87 | "ec2_instances_same_subnet_ssh.tf", 88 | "security_group_outbound_allow_all.tf", 89 | "security_group_inbound_allow_ssh.tf", 90 | }, 91 | trafficSSH(), 92 | reach.NewTrafficContentForAllTraffic(), 93 | }, 94 | { 95 | "all traffic", 96 | []string{ 97 | "ec2_instances_same_subnet_all_traffic.tf", 98 | "security_group_outbound_allow_all.tf", 99 | "security_group_inbound_allow_all.tf", 100 | }, 101 | reach.NewTrafficContentForAllTraffic(), 102 | reach.NewTrafficContentForAllTraffic(), 103 | }, 104 | }, 105 | }, 106 | { 107 | "same VPC", 108 | []string{ 109 | "main.tf", 110 | "outputs.tf", 111 | "ami_ubuntu.tf", 112 | "vpc.tf", 113 | "subnet_pair.tf", 114 | }, 115 | []testCase{ 116 | { 117 | "all traffic", 118 | []string{ 119 | "network_acl_both_subnets_all_traffic.tf", 120 | "ec2_instances_same_vpc_all_traffic.tf", 121 | "security_group_outbound_allow_all.tf", 122 | "security_group_inbound_allow_all.tf", 123 | }, 124 | reach.NewTrafficContentForAllTraffic(), 125 | reach.NewTrafficContentForAllTraffic(), 126 | }, 127 | { 128 | "no NACL allow rules", 129 | []string{ 130 | "network_acl_both_subnets_no_traffic.tf", 131 | "ec2_instances_same_vpc_all_traffic.tf", 132 | "security_group_outbound_allow_all.tf", 133 | "security_group_inbound_allow_all.tf", 134 | }, 135 | reach.NewTrafficContentForNoTraffic(), 136 | reach.NewTrafficContentForNoTraffic(), 137 | }, 138 | { 139 | "NACL rules don't match SG rules", 140 | []string{ 141 | "network_acl_both_subnets_all_tcp.tf", 142 | "ec2_instances_same_vpc_all_esp.tf", 143 | "security_group_outbound_allow_esp.tf", 144 | "security_group_inbound_allow_all.tf", 145 | }, 146 | reach.NewTrafficContentForNoTraffic(), 147 | trafficTCP(), // TODO: Revisit return traffic calculation for this scenario 148 | }, 149 | { 150 | "Postgres with tightened rules", 151 | []string{ 152 | "network_acl_source_subnet_tightened_postgres.tf", 153 | "network_acl_destination_subnet_tightened_postgres.tf", 154 | "ec2_instances_same_vpc_postgres.tf", 155 | "security_group_no_rules.tf", 156 | "security_group_outbound_allow_postgres_to_sg_no_rules.tf", 157 | "security_group_inbound_allow_postgres_from_sg_no_rules.tf", 158 | }, 159 | trafficPostgres(), 160 | trafficTCP(), 161 | }, 162 | }, 163 | }, 164 | } 165 | 166 | for _, g := range groupings { 167 | t.Run(g.name, func(t *testing.T) { 168 | for _, tc := range g.cases { 169 | t.Run(tc.name, func(t *testing.T) { 170 | // if tc.name != "Postgres with tightened rules" || g.name != "same VPC" { // TODO: remove this to run full test suite 171 | // t.SkipNow() 172 | // } 173 | 174 | // Setup (and deferred teardown) 175 | tf, err := terraform.New(t) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | defer func() { 180 | err = tf.CleanUp() 181 | if err != nil { 182 | t.Fatal(err) 183 | } 184 | }() 185 | 186 | err = tf.LoadFilesFromDir( 187 | "../acceptance/data/tf", 188 | append(g.files, tc.files...)..., 189 | ) 190 | if err != nil { 191 | t.Fatal(err) 192 | } 193 | 194 | err = tf.Init() 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | 199 | defer func() { 200 | err = tf.Destroy() // Putting this before apply so that we're not left with some resources not destroyed after failure from apply step. 201 | if err != nil { 202 | t.Fatal(err) 203 | } 204 | }() 205 | err = tf.PlanAndApply() 206 | if err != nil { 207 | t.Fatal(err) 208 | } 209 | 210 | sourceID, err := tf.Output("source_id") 211 | if err != nil { 212 | t.Fatal(err) 213 | } 214 | destinationID, err := tf.Output("destination_id") 215 | if err != nil { 216 | t.Fatal(err) 217 | } 218 | 219 | source, err := aws.NewEC2InstanceSubject(sourceID, reach.SubjectRoleSource) 220 | if err != nil { 221 | t.Fatal(err) 222 | } 223 | destination, err := aws.NewEC2InstanceSubject(destinationID, reach.SubjectRoleDestination) 224 | if err != nil { 225 | t.Fatal(err) 226 | } 227 | 228 | // Analyze 229 | 230 | analyzer := New() 231 | 232 | log.Print("analyzing...") 233 | analysis, err := analyzer.Analyze(source, destination) 234 | if err != nil { 235 | t.Fatal(err) 236 | } 237 | 238 | // Tests 239 | 240 | log.Print("verifying analysis results...") 241 | 242 | if forwardTraffic := analysis.NetworkVectors[0].Traffic; forwardTraffic.String() != tc.expectedForwardTraffic.String() { // TODO: consider a better comparison method besides strings 243 | t.Errorf("forward traffic -- expected:\n%v\nbut was:\n%v\n", tc.expectedForwardTraffic, forwardTraffic) 244 | } else { 245 | log.Print("✓ forward traffic content is correct") 246 | } 247 | 248 | if returnTraffic := analysis.NetworkVectors[0].ReturnTraffic; returnTraffic.String() != tc.expectedReturnTraffic.String() { 249 | t.Errorf("return traffic -- expected:\n%v\nbut was:\n%v\n", tc.expectedReturnTraffic, returnTraffic) 250 | } else { 251 | log.Print("✓ return traffic content is correct") 252 | } 253 | }) 254 | } 255 | }) 256 | } 257 | } 258 | 259 | func trafficSSH() reach.TrafficContent { 260 | ports, err := set.NewPortSetFromRange(22, 22) 261 | if err != nil { 262 | panic(err) 263 | } 264 | 265 | return reach.NewTrafficContentForPorts(reach.ProtocolTCP, ports) 266 | } 267 | 268 | func trafficHTTPS() reach.TrafficContent { 269 | ports, err := set.NewPortSetFromRange(443, 443) 270 | if err != nil { 271 | panic(err) 272 | } 273 | 274 | return reach.NewTrafficContentForPorts(reach.ProtocolTCP, ports) 275 | } 276 | 277 | func trafficDNS() reach.TrafficContent { 278 | ports, err := set.NewPortSetFromRange(53, 53) 279 | if err != nil { 280 | panic(err) 281 | } 282 | 283 | return reach.NewTrafficContentForPorts(reach.ProtocolUDP, ports) 284 | } 285 | 286 | func trafficESP() reach.TrafficContent { 287 | return reach.NewTrafficContentForCustomProtocol(50, true) 288 | } 289 | 290 | func trafficAssorted() reach.TrafficContent { 291 | tc, err := reach.NewTrafficContentFromMergingMultiple([]reach.TrafficContent{ 292 | trafficDNS(), 293 | trafficSSH(), 294 | trafficESP(), 295 | }) 296 | if err != nil { 297 | panic(err) 298 | } 299 | 300 | return tc 301 | } 302 | 303 | func trafficTCP() reach.TrafficContent { 304 | return reach.NewTrafficContentForPorts(reach.ProtocolTCP, set.NewFullPortSet()) 305 | } 306 | 307 | func trafficPostgres() reach.TrafficContent { 308 | ports, err := set.NewPortSetFromRange(5432, 5432) 309 | if err != nil { 310 | panic(err) 311 | } 312 | 313 | return reach.NewTrafficContentForPorts(reach.ProtocolTCP, ports) 314 | } 315 | -------------------------------------------------------------------------------- /reach/aws/api/ec2_instance.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/ec2" 8 | 9 | reachAWS "github.com/luhring/reach/reach/aws" 10 | ) 11 | 12 | // EC2Instance queries the AWS API for an EC2 instance matching the given ID. 13 | func (provider *ResourceProvider) EC2Instance(id string) (*reachAWS.EC2Instance, error) { 14 | input := &ec2.DescribeInstancesInput{ 15 | InstanceIds: []*string{ 16 | aws.String(id), 17 | }, 18 | } 19 | result, err := provider.ec2.DescribeInstances(input) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | instances, err := extractEC2Instances(result.Reservations) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | if len(instances) == 0 { 30 | return nil, fmt.Errorf("AWS API returned no instances for ID '%s'", id) 31 | } 32 | 33 | if len(instances) > 1 { 34 | return nil, fmt.Errorf("AWS API returned more than one instance for ID '%s'", id) 35 | } 36 | 37 | instance := instances[0] 38 | return &instance, nil 39 | } 40 | 41 | // AllEC2Instances queries the AWS API for all EC2 instances. 42 | func (provider *ResourceProvider) AllEC2Instances() ([]reachAWS.EC2Instance, error) { 43 | const errFormat = "unable to get all EC2 instances: %v" 44 | 45 | describeInstancesOutput, err := provider.ec2.DescribeInstances(nil) 46 | 47 | if err != nil { 48 | return nil, fmt.Errorf(errFormat, err) 49 | } 50 | 51 | reservations := describeInstancesOutput.Reservations 52 | instances, err := extractEC2Instances(reservations) 53 | if err != nil { 54 | return nil, fmt.Errorf(errFormat, err) 55 | } 56 | 57 | return instances, nil 58 | } 59 | 60 | func newEC2InstanceFromAPI(instance *ec2.Instance) reachAWS.EC2Instance { 61 | return reachAWS.EC2Instance{ 62 | ID: aws.StringValue(instance.InstanceId), 63 | NameTag: nameTag(instance.Tags), 64 | State: aws.StringValue(instance.State.Name), 65 | NetworkInterfaceAttachments: networkInterfaceAttachments(instance), 66 | } 67 | } 68 | 69 | func extractEC2Instances(reservations []*ec2.Reservation) ([]reachAWS.EC2Instance, error) { 70 | var instances []reachAWS.EC2Instance 71 | 72 | for _, r := range reservations { 73 | for _, i := range r.Instances { 74 | instance := newEC2InstanceFromAPI(i) 75 | instances = append(instances, instance) 76 | } 77 | } 78 | 79 | return instances, nil 80 | } 81 | 82 | func networkInterfaceAttachments(instance *ec2.Instance) []reachAWS.NetworkInterfaceAttachment { 83 | var attachments []reachAWS.NetworkInterfaceAttachment 84 | 85 | if instance.NetworkInterfaces != nil && len(instance.NetworkInterfaces) > 0 { 86 | for _, networkInterface := range instance.NetworkInterfaces { 87 | attachments = append(attachments, newNetworkInterfaceAttachmentFromAPI(networkInterface)) 88 | } 89 | } 90 | 91 | return attachments 92 | } 93 | 94 | func newNetworkInterfaceAttachmentFromAPI(networkInterface *ec2.InstanceNetworkInterface) reachAWS.NetworkInterfaceAttachment { 95 | return reachAWS.NetworkInterfaceAttachment{ 96 | ID: aws.StringValue(networkInterface.Attachment.AttachmentId), 97 | ElasticNetworkInterfaceID: aws.StringValue(networkInterface.NetworkInterfaceId), 98 | DeviceIndex: aws.Int64Value(networkInterface.Attachment.DeviceIndex), 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /reach/aws/api/elastic_network_interface.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/ec2" 8 | 9 | reachAWS "github.com/luhring/reach/reach/aws" 10 | ) 11 | 12 | // ElasticNetworkInterface queries the AWS API for an elastic network interface matching the given ID. 13 | func (provider *ResourceProvider) ElasticNetworkInterface(id string) (*reachAWS.ElasticNetworkInterface, error) { 14 | input := &ec2.DescribeNetworkInterfacesInput{ 15 | NetworkInterfaceIds: []*string{ 16 | aws.String(id), 17 | }, 18 | } 19 | result, err := provider.ec2.DescribeNetworkInterfaces(input) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | if err = ensureSingleResult(len(result.NetworkInterfaces), "elastic network interface", id); err != nil { 25 | return nil, err 26 | } 27 | 28 | networkInterface := newElasticNetworkInterfaceFromAPI(result.NetworkInterfaces[0]) 29 | return &networkInterface, nil 30 | } 31 | 32 | func newElasticNetworkInterfaceFromAPI(eni *ec2.NetworkInterface) reachAWS.ElasticNetworkInterface { 33 | publicIPv4Address := publicIPAddress(eni.Association) 34 | privateIPv4Addresses := privateIPAddresses(eni.PrivateIpAddresses) 35 | ipv6Addresses := ipv6Addresses(eni.Ipv6Addresses) 36 | 37 | return reachAWS.ElasticNetworkInterface{ 38 | ID: aws.StringValue(eni.NetworkInterfaceId), 39 | NameTag: nameTag(eni.TagSet), 40 | SubnetID: aws.StringValue(eni.SubnetId), 41 | VPCID: aws.StringValue(eni.VpcId), 42 | SecurityGroupIDs: securityGroupIDs(eni.Groups), 43 | PublicIPv4Address: publicIPv4Address, 44 | PrivateIPv4Addresses: privateIPv4Addresses, 45 | IPv6Addresses: ipv6Addresses, 46 | } 47 | } 48 | 49 | func securityGroupID(identifier *ec2.GroupIdentifier) string { 50 | if identifier == nil { 51 | return "" 52 | } 53 | 54 | return aws.StringValue(identifier.GroupId) 55 | } 56 | 57 | func securityGroupIDs(identifiers []*ec2.GroupIdentifier) []string { 58 | ids := make([]string, len(identifiers)) 59 | 60 | for i, identifier := range identifiers { 61 | ids[i] = securityGroupID(identifier) 62 | } 63 | 64 | return ids 65 | } 66 | 67 | func privateIPAddress(address *ec2.NetworkInterfacePrivateIpAddress) net.IP { 68 | if address == nil { 69 | return net.IP{} 70 | } 71 | 72 | return net.ParseIP(aws.StringValue(address.PrivateIpAddress)) 73 | } 74 | 75 | func privateIPAddresses(addresses []*ec2.NetworkInterfacePrivateIpAddress) []net.IP { 76 | ips := make([]net.IP, len(addresses)) 77 | 78 | for i, address := range addresses { 79 | ips[i] = privateIPAddress(address) 80 | } 81 | 82 | return ips 83 | } 84 | 85 | func ipv6Address(address *ec2.NetworkInterfaceIpv6Address) net.IP { 86 | if address == nil { 87 | return net.IP{} 88 | } 89 | 90 | return net.ParseIP(aws.StringValue(address.Ipv6Address)) 91 | } 92 | 93 | func ipv6Addresses(addresses []*ec2.NetworkInterfaceIpv6Address) []net.IP { 94 | ips := make([]net.IP, len(addresses)) 95 | 96 | for i, address := range addresses { 97 | ips[i] = ipv6Address(address) 98 | } 99 | 100 | return ips 101 | } 102 | 103 | func publicIPAddress(association *ec2.NetworkInterfaceAssociation) net.IP { 104 | if association == nil { 105 | return net.IP{} 106 | } 107 | 108 | return net.ParseIP(aws.StringValue(association.PublicIp)) 109 | } 110 | -------------------------------------------------------------------------------- /reach/aws/api/network_acl.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | 10 | "github.com/luhring/reach/reach" 11 | reachAWS "github.com/luhring/reach/reach/aws" 12 | ) 13 | 14 | // NetworkACL queries the AWS API for a network ACL matching the given ID. 15 | func (provider *ResourceProvider) NetworkACL(id string) (*reachAWS.NetworkACL, error) { 16 | input := &ec2.DescribeNetworkAclsInput{ 17 | NetworkAclIds: []*string{ 18 | aws.String(id), 19 | }, 20 | } 21 | result, err := provider.ec2.DescribeNetworkAcls(input) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | if err = ensureSingleResult(len(result.NetworkAcls), "network ACL", id); err != nil { 27 | return nil, err 28 | } 29 | 30 | networkACL := newNetworkACLFromAPI(result.NetworkAcls[0]) 31 | return &networkACL, nil 32 | } 33 | 34 | func newNetworkACLFromAPI(networkACL *ec2.NetworkAcl) reachAWS.NetworkACL { 35 | inboundRules := inboundNetworkACLRules(networkACL.Entries) 36 | outboundRules := outboundNetworkACLRules(networkACL.Entries) 37 | 38 | return reachAWS.NetworkACL{ 39 | ID: aws.StringValue(networkACL.NetworkAclId), 40 | InboundRules: inboundRules, 41 | OutboundRules: outboundRules, 42 | } 43 | } 44 | 45 | func networkACLRulesForSingleDirection(entries []*ec2.NetworkAclEntry, inbound bool) []reachAWS.NetworkACLRule { 46 | if entries == nil { 47 | return nil 48 | } 49 | 50 | rules := []reachAWS.NetworkACLRule{} 51 | 52 | for _, entry := range entries { 53 | if entry != nil { 54 | if inbound != aws.BoolValue(entry.Egress) { 55 | rules = append(rules, networkACLRule(entry)) 56 | } 57 | } 58 | } 59 | 60 | return rules 61 | } 62 | 63 | func inboundNetworkACLRules(entries []*ec2.NetworkAclEntry) []reachAWS.NetworkACLRule { 64 | return networkACLRulesForSingleDirection(entries, true) 65 | } 66 | 67 | func outboundNetworkACLRules(entries []*ec2.NetworkAclEntry) []reachAWS.NetworkACLRule { 68 | return networkACLRulesForSingleDirection(entries, false) 69 | } 70 | 71 | func networkACLRule(entry *ec2.NetworkAclEntry) reachAWS.NetworkACLRule { // note: this function ignores rule direction (inbound vs. outbound) 72 | if entry == nil { 73 | return reachAWS.NetworkACLRule{} 74 | } 75 | 76 | _, targetIPNetwork, err := net.ParseCIDR(aws.StringValue(entry.CidrBlock)) 77 | if err != nil { 78 | return reachAWS.NetworkACLRule{} 79 | } 80 | 81 | var action reachAWS.NetworkACLRuleAction 82 | 83 | if aws.StringValue(entry.RuleAction) == ec2.RuleActionAllow { 84 | action = reachAWS.NetworkACLRuleActionAllow 85 | } else { 86 | action = reachAWS.NetworkACLRuleActionDeny 87 | } 88 | 89 | tc, err := newTrafficContentFromAWSNACLEntry(entry) 90 | 91 | if err != nil { 92 | panic(err) // TODO: Better error handling 93 | } 94 | 95 | return reachAWS.NetworkACLRule{ 96 | Number: aws.Int64Value(entry.RuleNumber), 97 | TrafficContent: tc, 98 | TargetIPNetwork: targetIPNetwork, 99 | Action: action, 100 | } 101 | } 102 | 103 | func newTrafficContentFromAWSNACLEntry(entry *ec2.NetworkAclEntry) (reach.TrafficContent, error) { // TODO: BUG! This needs to consider what rules preempt this rule, and handle set subtractions accordingly 104 | const errCreation = "unable to create content: %v" 105 | 106 | protocol, err := convertAWSIPProtocolStringToProtocol(entry.Protocol) 107 | if err != nil { 108 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 109 | } 110 | 111 | if protocol == reach.ProtocolAll { 112 | return reach.NewTrafficContentForAllTraffic(), nil 113 | } 114 | 115 | if protocol.UsesPorts() { 116 | portSet, err := newPortSetFromAWSPortRange(entry.PortRange) 117 | if err != nil { 118 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 119 | } 120 | 121 | return reach.NewTrafficContentForPorts(protocol, portSet), nil 122 | } 123 | 124 | if protocol == reach.ProtocolICMPv4 || protocol == reach.ProtocolICMPv6 { 125 | icmpSet, err := newICMPSetFromAWSICMPTypeCode(entry.IcmpTypeCode) 126 | if err != nil { 127 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 128 | } 129 | 130 | return reach.NewTrafficContentForICMP(protocol, icmpSet), nil 131 | } 132 | 133 | return reach.NewTrafficContentForCustomProtocol(protocol, true), nil 134 | } 135 | -------------------------------------------------------------------------------- /reach/aws/api/resource_provider.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/ec2" 12 | 13 | "github.com/luhring/reach/reach" 14 | ) 15 | 16 | // ResourceProvider implements an AWS resource provider using the AWS API (via the AWS SDK). 17 | type ResourceProvider struct { 18 | session *session.Session 19 | ec2 *ec2.EC2 20 | } 21 | 22 | // NewResourceProvider returns a reference to a new ResourceProvider for the AWS API. 23 | func NewResourceProvider() *ResourceProvider { 24 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 25 | SharedConfigState: session.SharedConfigEnable, 26 | })) // TODO: Don't call session.Must —- return error, and don't panic, this is a lib after all! 27 | 28 | ec2Client := ec2.New(sess) 29 | 30 | return &ResourceProvider{ 31 | session: sess, 32 | ec2: ec2Client, 33 | } 34 | } 35 | 36 | func nameTag(tags []*ec2.Tag) string { 37 | if tags != nil && len(tags) > 0 { 38 | for _, tag := range tags { 39 | if aws.StringValue(tag.Key) == "Name" { 40 | return aws.StringValue(tag.Value) 41 | } 42 | } 43 | } 44 | 45 | return "" 46 | } 47 | 48 | func ensureSingleResult(resultSetLength int, entity, id string) error { 49 | if resultSetLength == 0 { 50 | return fmt.Errorf("AWS API did not return a %s for ID '%s'", entity, id) 51 | } 52 | 53 | if resultSetLength > 1 { 54 | return fmt.Errorf("AWS API returned more than one %s for ID '%s'", entity, id) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func convertAWSIPProtocolStringToProtocol(ipProtocol *string) (reach.Protocol, error) { 61 | if ipProtocol == nil { 62 | return 0, errors.New("unexpected nil ipProtocol") 63 | } 64 | 65 | protocolString := strings.ToLower(aws.StringValue(ipProtocol)) 66 | 67 | if p, err := strconv.ParseInt(protocolString, 10, 64); err == nil { 68 | var protocol = reach.Protocol(p) 69 | return protocol, nil 70 | } 71 | 72 | var protocolNumber reach.Protocol 73 | 74 | switch protocolString { 75 | case "tcp": 76 | protocolNumber = reach.ProtocolTCP 77 | case "udp": 78 | protocolNumber = reach.ProtocolUDP 79 | case "icmp": 80 | protocolNumber = reach.ProtocolICMPv4 81 | case "icmpv6": 82 | protocolNumber = reach.ProtocolICMPv6 83 | default: 84 | return 0, errors.New("unrecognized ipProtocol value") 85 | } 86 | 87 | return protocolNumber, nil 88 | } 89 | -------------------------------------------------------------------------------- /reach/aws/api/route_table.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ec2" 6 | 7 | reachAWS "github.com/luhring/reach/reach/aws" 8 | ) 9 | 10 | // RouteTable queries the AWS API for a route table matching the given ID. 11 | func (provider *ResourceProvider) RouteTable(id string) (*reachAWS.RouteTable, error) { 12 | input := &ec2.DescribeRouteTablesInput{ 13 | RouteTableIds: []*string{ 14 | aws.String(id), 15 | }, 16 | } 17 | result, err := provider.ec2.DescribeRouteTables(input) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | if err = ensureSingleResult(len(result.RouteTables), "security group", id); err != nil { 23 | return nil, err 24 | } 25 | 26 | routeTable := newRouteTableFromAPI(result.RouteTables[0]) 27 | return &routeTable, nil 28 | } 29 | 30 | func newRouteTableFromAPI(routeTable *ec2.RouteTable) reachAWS.RouteTable { 31 | routes := []reachAWS.RouteTableRoute{} // TODO: implement 32 | 33 | return reachAWS.RouteTable{ 34 | ID: aws.StringValue(routeTable.RouteTableId), 35 | VPCID: aws.StringValue(routeTable.VpcId), 36 | Routes: routes, 37 | } 38 | } 39 | 40 | func routeTableRoutes(routes []*ec2.Route) []reachAWS.RouteTableRoute { 41 | return nil // TODO: implement 42 | } 43 | 44 | func routeTableRoute(route *ec2.Route) reachAWS.RouteTableRoute { 45 | if route == nil { 46 | return reachAWS.RouteTableRoute{} 47 | } 48 | 49 | panic("need to finish implementing") 50 | } 51 | -------------------------------------------------------------------------------- /reach/aws/api/security_group.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | 10 | "github.com/luhring/reach/reach" 11 | reachAWS "github.com/luhring/reach/reach/aws" 12 | "github.com/luhring/reach/reach/set" 13 | ) 14 | 15 | // SecurityGroup queries the AWS API for a security group matching the given ID. 16 | func (provider *ResourceProvider) SecurityGroup(id string) (*reachAWS.SecurityGroup, error) { 17 | input := &ec2.DescribeSecurityGroupsInput{ 18 | GroupIds: []*string{ 19 | aws.String(id), 20 | }, 21 | } 22 | result, err := provider.ec2.DescribeSecurityGroups(input) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | if err = ensureSingleResult(len(result.SecurityGroups), "security group", id); err != nil { 28 | return nil, err 29 | } 30 | 31 | securityGroup := newSecurityGroupFromAPI(result.SecurityGroups[0]) 32 | return &securityGroup, nil 33 | } 34 | 35 | func newSecurityGroupFromAPI(securityGroup *ec2.SecurityGroup) reachAWS.SecurityGroup { 36 | inboundRules := securityGroupRules(securityGroup.IpPermissions) 37 | outboundRules := securityGroupRules(securityGroup.IpPermissionsEgress) 38 | 39 | return reachAWS.SecurityGroup{ 40 | ID: aws.StringValue(securityGroup.GroupId), 41 | NameTag: nameTag(securityGroup.Tags), 42 | GroupName: aws.StringValue(securityGroup.GroupName), 43 | VPCID: aws.StringValue(securityGroup.VpcId), 44 | InboundRules: inboundRules, 45 | OutboundRules: outboundRules, 46 | } 47 | } 48 | 49 | func securityGroupRules(inputRules []*ec2.IpPermission) []reachAWS.SecurityGroupRule { 50 | if inputRules == nil { 51 | return nil 52 | } 53 | 54 | rules := make([]reachAWS.SecurityGroupRule, len(inputRules)) 55 | 56 | for i, inputRule := range inputRules { 57 | if inputRule != nil { 58 | rules[i] = securityGroupRule(inputRule) 59 | } 60 | } 61 | 62 | return rules 63 | } 64 | 65 | func securityGroupRule(rule *ec2.IpPermission) reachAWS.SecurityGroupRule { // note: this function ignores rule direction (inbound vs. outbound) 66 | if rule == nil { 67 | return reachAWS.SecurityGroupRule{} 68 | } 69 | 70 | tc, err := trafficContentFromAWSIPPermission(rule) 71 | if err != nil { 72 | panic(err) // TODO: Better error handling 73 | } 74 | 75 | // TODO: see if we really need to handle multiple pairs -- the docs don't mention this capability -- https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html#SecurityGroupRules 76 | 77 | var targetSecurityGroupReferenceID, targetSecurityGroupReferenceAccountID string 78 | 79 | if rule.UserIdGroupPairs != nil { 80 | firstPair := rule.UserIdGroupPairs[0] // if panicking, see above to-do... 81 | targetSecurityGroupReferenceID = securityGroupReferenceID(firstPair) 82 | targetSecurityGroupReferenceAccountID = securityGroupReferenceAccountID(firstPair) 83 | } 84 | 85 | // TODO: Handle prefix lists (and thus VPC endpoints) 86 | // for context: https://docs.aws.amazon.com/vpc/latest/userguide/vpce-gateway.html 87 | 88 | targetIPNetworks := ipNetworksFromSecurityGroupRule(rule.IpRanges, rule.Ipv6Ranges) 89 | 90 | return reachAWS.SecurityGroupRule{ 91 | TrafficContent: tc, 92 | TargetSecurityGroupReferenceID: targetSecurityGroupReferenceID, 93 | TargetSecurityGroupReferenceAccountID: targetSecurityGroupReferenceAccountID, 94 | TargetIPNetworks: targetIPNetworks, 95 | } 96 | } 97 | 98 | func newPortSetFromAWSPortRange(portRange *ec2.PortRange) (set.PortSet, error) { 99 | if portRange == nil { 100 | return set.PortSet{}, fmt.Errorf("input portRange was nil") 101 | } 102 | 103 | from := aws.Int64Value(portRange.From) 104 | to := aws.Int64Value(portRange.To) 105 | 106 | return set.NewPortSetFromRange(uint16(from), uint16(to)) 107 | } 108 | 109 | func newPortSetFromAWSIPPermission(permission *ec2.IpPermission) (set.PortSet, error) { 110 | if permission == nil { 111 | return set.PortSet{}, fmt.Errorf("input IpPermission was nil") 112 | } 113 | 114 | from := aws.Int64Value(permission.FromPort) 115 | to := aws.Int64Value(permission.ToPort) 116 | 117 | return set.NewPortSetFromRange(uint16(from), uint16(to)) 118 | } 119 | 120 | func securityGroupReferenceID(pair *ec2.UserIdGroupPair) string { 121 | if pair == nil { 122 | return "" 123 | } 124 | 125 | return aws.StringValue(pair.GroupId) 126 | } 127 | 128 | func securityGroupReferenceAccountID(pair *ec2.UserIdGroupPair) string { 129 | if pair == nil { 130 | return "" 131 | } 132 | 133 | return aws.StringValue(pair.UserId) 134 | } 135 | 136 | func ipNetworksFromSecurityGroupRule(ipv4Ranges []*ec2.IpRange, ipv6Ranges []*ec2.Ipv6Range) []*net.IPNet { 137 | networks := make([]*net.IPNet, len(ipv4Ranges)+len(ipv6Ranges)) 138 | 139 | for i, block := range ipv4Ranges { 140 | if block != nil { 141 | _, network, err := net.ParseCIDR(aws.StringValue(block.CidrIp)) 142 | if err == nil { 143 | networks[i] = network 144 | } 145 | } 146 | } 147 | 148 | for i, block := range ipv6Ranges { 149 | if block != nil { 150 | _, network, err := net.ParseCIDR(aws.StringValue(block.CidrIpv6)) 151 | if err == nil { 152 | networks[len(ipv4Ranges)+i] = network 153 | } 154 | } 155 | } 156 | 157 | return networks 158 | } 159 | 160 | func trafficContentFromAWSIPPermission(permission *ec2.IpPermission) (reach.TrafficContent, error) { 161 | const errCreation = "unable to create content: %v" 162 | 163 | protocol, err := convertAWSIPProtocolStringToProtocol(permission.IpProtocol) 164 | if err != nil { 165 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 166 | } 167 | 168 | if protocol == reach.ProtocolAll { 169 | return reach.NewTrafficContentForAllTraffic(), nil 170 | } 171 | 172 | if protocol.UsesPorts() { 173 | portSet, err := newPortSetFromAWSIPPermission(permission) 174 | if err != nil { 175 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 176 | } 177 | 178 | return reach.NewTrafficContentForPorts(protocol, portSet), nil 179 | } 180 | 181 | if protocol == reach.ProtocolICMPv4 || protocol == reach.ProtocolICMPv6 { 182 | icmpSet, err := newICMPSetFromAWSIPPermission(permission) 183 | if err != nil { 184 | return reach.TrafficContent{}, fmt.Errorf(errCreation, err) 185 | } 186 | 187 | return reach.NewTrafficContentForICMP(protocol, icmpSet), nil 188 | } 189 | 190 | return reach.NewTrafficContentForCustomProtocol(protocol, true), nil 191 | } 192 | 193 | func newICMPSetFromAWSICMPTypeCode(icmpTypeCode *ec2.IcmpTypeCode) (set.ICMPSet, error) { 194 | if icmpTypeCode == nil { 195 | return set.ICMPSet{}, fmt.Errorf("input icmpTypeCode was nil") 196 | } 197 | 198 | icmpType := aws.Int64Value(icmpTypeCode.Type) 199 | 200 | if icmpType == set.AllICMPTypes { 201 | result := set.NewFullICMPSet() 202 | return result, nil 203 | } 204 | 205 | icmpTypeValue := uint8(icmpType) // i.e. equivalent to ICMP header value 206 | 207 | icmpCode := aws.Int64Value(icmpTypeCode.Code) 208 | 209 | if icmpCode == set.AllICMPCodes { 210 | return set.NewICMPSetFromICMPType(icmpTypeValue) 211 | } 212 | 213 | icmpCodeValue := uint8(icmpCode) // i.e. equivalent to ICMP header value 214 | 215 | return set.NewICMPSetFromICMPTypeCode(icmpTypeValue, icmpCodeValue) 216 | } 217 | 218 | func newICMPSetFromAWSIPPermission(permission *ec2.IpPermission) (set.ICMPSet, error) { 219 | if permission == nil { 220 | return set.ICMPSet{}, fmt.Errorf("input IpPermission was nil") 221 | } 222 | 223 | icmpType := aws.Int64Value(permission.FromPort) 224 | 225 | if icmpType == set.AllICMPTypes { 226 | result := set.NewFullICMPSet() 227 | return result, nil 228 | } 229 | 230 | icmpTypeValue := uint8(icmpType) // i.e. equivalent to ICMP header value 231 | 232 | icmpCode := aws.Int64Value(permission.ToPort) 233 | 234 | if icmpCode == set.AllICMPCodes { 235 | return set.NewICMPSetFromICMPType(icmpTypeValue) 236 | } 237 | 238 | icmpCodeValue := uint8(icmpCode) // i.e. equivalent to ICMP header value 239 | 240 | return set.NewICMPSetFromICMPTypeCode(icmpTypeValue, icmpCodeValue) 241 | } 242 | -------------------------------------------------------------------------------- /reach/aws/api/security_group_reference.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | reachAWS "github.com/luhring/reach/reach/aws" 5 | ) 6 | 7 | // SecurityGroupReference queries the AWS API for a security group matching the given ID, but returns a security group reference representation instead of the full security group representation. 8 | func (provider *ResourceProvider) SecurityGroupReference(id, accountID string) (*reachAWS.SecurityGroupReference, error) { 9 | // TODO: Incorporate account ID in search. 10 | // In the meantime, this will be a known bug, where other accounts are not considered. 11 | 12 | sg, err := provider.SecurityGroup(id) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return &reachAWS.SecurityGroupReference{ 18 | ID: sg.ID, 19 | AccountID: "", 20 | NameTag: sg.NameTag, 21 | GroupName: sg.GroupName, 22 | }, nil 23 | } 24 | -------------------------------------------------------------------------------- /reach/aws/api/subnet.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ec2" 6 | 7 | reachAWS "github.com/luhring/reach/reach/aws" 8 | ) 9 | 10 | // Subnet queries the AWS API for a subnet matching the given ID. 11 | func (provider *ResourceProvider) Subnet(id string) (*reachAWS.Subnet, error) { 12 | input := &ec2.DescribeSubnetsInput{ 13 | SubnetIds: []*string{ 14 | aws.String(id), 15 | }, 16 | } 17 | result, err := provider.ec2.DescribeSubnets(input) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | if err = ensureSingleResult(len(result.Subnets), "subnet", id); err != nil { 23 | return nil, err 24 | } 25 | 26 | awsSubnet := result.Subnets[0] 27 | networkACLID, err := provider.networkACLIDFromSubnetID(aws.StringValue(awsSubnet.SubnetId)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | subnet := newSubnetFromAPI(result.Subnets[0], networkACLID) 33 | return &subnet, nil 34 | } 35 | 36 | func newSubnetFromAPI(subnet *ec2.Subnet, networkACLID string) reachAWS.Subnet { 37 | return reachAWS.Subnet{ 38 | ID: aws.StringValue(subnet.SubnetId), 39 | NetworkACLID: networkACLID, 40 | VPCID: aws.StringValue(subnet.VpcId), 41 | } 42 | } 43 | 44 | func (provider *ResourceProvider) networkACLIDFromSubnetID(id string) (string, error) { 45 | input := &ec2.DescribeNetworkAclsInput{ 46 | Filters: []*ec2.Filter{ 47 | { 48 | Name: aws.String("association.subnet-id"), 49 | Values: []*string{ 50 | aws.String(id), 51 | }, 52 | }, 53 | }, 54 | } 55 | result, err := provider.ec2.DescribeNetworkAcls(input) 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | if err = ensureSingleResult(len(result.NetworkAcls), "network ACL (via subnet)", id); err != nil { 61 | return "", err 62 | } 63 | 64 | return aws.StringValue(result.NetworkAcls[0].NetworkAclId), nil 65 | } 66 | -------------------------------------------------------------------------------- /reach/aws/api/vpc.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/ec2" 8 | 9 | reachAWS "github.com/luhring/reach/reach/aws" 10 | ) 11 | 12 | // VPC queries the AWS API for a VPC matching the given ID. 13 | func (provider *ResourceProvider) VPC(id string) (*reachAWS.VPC, error) { 14 | input := &ec2.DescribeVpcsInput{ 15 | VpcIds: []*string{ 16 | aws.String(id), 17 | }, 18 | } 19 | result, err := provider.ec2.DescribeVpcs(input) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | if err = ensureSingleResult(len(result.Vpcs), "VPC", id); err != nil { 25 | return nil, err 26 | } 27 | 28 | vpc := newVPCFromAPI(result.Vpcs[0]) 29 | return &vpc, nil 30 | } 31 | 32 | func newVPCFromAPI(vpc *ec2.Vpc) reachAWS.VPC { 33 | ipv4CIDRs := cidrs(vpc.CidrBlockAssociationSet) 34 | ipv6CIDRs := ipv6CIDRs(vpc.Ipv6CidrBlockAssociationSet) 35 | 36 | return reachAWS.VPC{ 37 | ID: aws.StringValue(vpc.VpcId), 38 | IPv4CIDRs: ipv4CIDRs, 39 | IPv6CIDRs: ipv6CIDRs, 40 | } 41 | } 42 | 43 | func cidrs(associationSet []*ec2.VpcCidrBlockAssociation) []net.IPNet { 44 | cidrs := make([]net.IPNet, len(associationSet)) 45 | 46 | for i, association := range associationSet { 47 | cidrs[i] = cidr(association) 48 | } 49 | 50 | return cidrs 51 | } 52 | 53 | func cidr(association *ec2.VpcCidrBlockAssociation) net.IPNet { 54 | if association == nil { 55 | return net.IPNet{} 56 | } 57 | 58 | _, cidr, err := net.ParseCIDR(aws.StringValue(association.CidrBlock)) 59 | if err != nil { 60 | return net.IPNet{} 61 | } 62 | 63 | return *cidr 64 | } 65 | 66 | func ipv6CIDRs(associationSet []*ec2.VpcIpv6CidrBlockAssociation) []net.IPNet { 67 | cidrs := make([]net.IPNet, len(associationSet)) 68 | 69 | for i, association := range associationSet { 70 | cidrs[i] = ipv6CIDR(association) 71 | } 72 | 73 | return cidrs 74 | } 75 | 76 | func ipv6CIDR(association *ec2.VpcIpv6CidrBlockAssociation) net.IPNet { 77 | if association == nil { 78 | return net.IPNet{} 79 | } 80 | 81 | _, cidr, err := net.ParseCIDR(aws.StringValue(association.Ipv6CidrBlock)) 82 | if err != nil { 83 | return net.IPNet{} 84 | } 85 | 86 | return *cidr 87 | } 88 | -------------------------------------------------------------------------------- /reach/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | // ResourceDomainAWS is the domain that represents AWS (Amazon Web Services), such that any AWS-specific kinds of resources can be categorized and operated on as such. 4 | const ResourceDomainAWS = "aws" 5 | -------------------------------------------------------------------------------- /reach/aws/ec2_instance.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/luhring/reach/reach" 8 | ) 9 | 10 | // ResourceKindEC2Instance specifies the unique name for the EC2 instance kind of resource. 11 | const ResourceKindEC2Instance = "EC2Instance" 12 | 13 | // An EC2Instance resource representation. 14 | type EC2Instance struct { 15 | ID string 16 | NameTag string `json:"NameTag,omitempty"` 17 | State string 18 | NetworkInterfaceAttachments []NetworkInterfaceAttachment 19 | } 20 | 21 | // ToResource returns the EC2 instance converted to a generalized Reach resource. 22 | func (i EC2Instance) ToResource() reach.Resource { 23 | return reach.Resource{ 24 | Kind: ResourceKindEC2Instance, 25 | Properties: i, 26 | } 27 | } 28 | 29 | // ToResourceReference returns a resource reference to uniquely identify the EC2 instance. 30 | func (i EC2Instance) ToResourceReference() reach.ResourceReference { 31 | return reach.ResourceReference{ 32 | Domain: ResourceDomainAWS, 33 | Kind: ResourceKindEC2Instance, 34 | ID: i.ID, 35 | } 36 | } 37 | 38 | func (i EC2Instance) isRunning() bool { 39 | return i.State == "running" 40 | } 41 | 42 | func (i EC2Instance) elasticNetworkInterfaceIDs() []string { 43 | var ids []string 44 | 45 | for _, attachment := range i.NetworkInterfaceAttachments { 46 | ids = append(ids, attachment.ElasticNetworkInterfaceID) 47 | } 48 | 49 | return ids 50 | } 51 | 52 | // Dependencies returns a collection of the EC2 instance's resource dependencies. 53 | func (i EC2Instance) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 54 | rc := reach.NewResourceCollection() 55 | 56 | for _, attachment := range i.NetworkInterfaceAttachments { 57 | attachmentDependencies, err := attachment.Dependencies(provider) 58 | if err != nil { 59 | return nil, err 60 | } 61 | rc.Merge(attachmentDependencies) 62 | } 63 | 64 | return rc, nil 65 | } 66 | 67 | func (i EC2Instance) networkPoints(rc *reach.ResourceCollection) []reach.NetworkPoint { 68 | var points []reach.NetworkPoint 69 | 70 | for _, id := range i.elasticNetworkInterfaceIDs() { 71 | eni := rc.Get(reach.ResourceReference{ 72 | Domain: ResourceDomainAWS, 73 | Kind: ResourceKindElasticNetworkInterface, 74 | ID: id, 75 | }).Properties.(ElasticNetworkInterface) 76 | eniNetworkPoints := eni.getNetworkPoints(i.ToResourceReference()) 77 | points = append(points, eniNetworkPoints...) 78 | } 79 | 80 | return points 81 | } 82 | 83 | // Name returns the instance's ID, and, if available, its name tag value. 84 | func (i EC2Instance) Name() string { 85 | if name := strings.TrimSpace(i.NameTag); name != "" { 86 | return fmt.Sprintf("\"%s\" (%s)", name, i.ID) 87 | } 88 | return i.ID 89 | } 90 | -------------------------------------------------------------------------------- /reach/aws/ec2_instance_subject.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // SubjectKindEC2Instance specifies the unique name for the EC2 instance kind of subject. 6 | const SubjectKindEC2Instance = "EC2Instance" 7 | 8 | // NewEC2InstanceSubject returns a new subject for the specified EC2 instance. 9 | func NewEC2InstanceSubject(id string, role reach.SubjectRole) (*reach.Subject, error) { 10 | if !reach.ValidSubjectRole(role) { 11 | return nil, reach.NewSubjectError(reach.ErrSubjectRoleValidation) 12 | } 13 | 14 | if len(id) < 1 { 15 | return nil, reach.NewSubjectError(reach.ErrSubjectIDValidation) 16 | } 17 | 18 | return &reach.Subject{ 19 | Domain: ResourceDomainAWS, 20 | Kind: SubjectKindEC2Instance, 21 | ID: id, 22 | Role: role, 23 | }, nil 24 | } 25 | -------------------------------------------------------------------------------- /reach/aws/ec2_instance_subject_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/luhring/reach/reach" 8 | ) 9 | 10 | func TestNewEC2InstanceSubject(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | id string 14 | role reach.SubjectRole 15 | expectedSubject *reach.Subject 16 | expectedError error 17 | }{ 18 | { 19 | name: "valid input with source role", 20 | id: "i-abc123", 21 | role: reach.SubjectRoleSource, 22 | expectedSubject: &reach.Subject{ 23 | Domain: ResourceDomainAWS, 24 | Kind: SubjectKindEC2Instance, 25 | ID: "i-abc123", 26 | Role: reach.SubjectRoleSource, 27 | }, 28 | expectedError: nil, 29 | }, 30 | { 31 | name: "valid input with destination role", 32 | id: "i-def456", 33 | role: reach.SubjectRoleDestination, 34 | expectedSubject: &reach.Subject{ 35 | Domain: ResourceDomainAWS, 36 | Kind: SubjectKindEC2Instance, 37 | ID: "i-def456", 38 | Role: reach.SubjectRoleDestination, 39 | }, 40 | expectedError: nil, 41 | }, 42 | { 43 | name: "invalid ID value", 44 | id: "", 45 | role: reach.SubjectRoleSource, 46 | expectedSubject: nil, 47 | expectedError: reach.NewSubjectError(reach.ErrSubjectIDValidation), 48 | }, 49 | { 50 | name: "invalid role value", 51 | id: "i-abc123", 52 | role: "custom-role", 53 | expectedSubject: nil, 54 | expectedError: reach.NewSubjectError(reach.ErrSubjectRoleValidation), 55 | }, 56 | } 57 | 58 | for _, tc := range cases { 59 | t.Run(tc.name, func(t *testing.T) { 60 | subj, err := NewEC2InstanceSubject(tc.id, tc.role) 61 | 62 | if !reflect.DeepEqual(tc.expectedSubject, subj) { 63 | reach.DiffErrorf(t, "subj", tc.expectedSubject, subj) 64 | } 65 | 66 | if !reflect.DeepEqual(tc.expectedError, err) { 67 | reach.DiffErrorf(t, "err", tc.expectedError, err) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /reach/aws/elastic_network_interface.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | 8 | "github.com/luhring/reach/reach" 9 | ) 10 | 11 | // ResourceKindElasticNetworkInterface specifies the unique name for the elastic network interface kind of resource. 12 | const ResourceKindElasticNetworkInterface = "ElasticNetworkInterface" 13 | 14 | // An ElasticNetworkInterface resource representation. 15 | type ElasticNetworkInterface struct { 16 | ID string 17 | NameTag string `json:"NameTag,omitempty"` 18 | SubnetID string 19 | VPCID string 20 | SecurityGroupIDs []string 21 | PublicIPv4Address net.IP `json:"PublicIPv4Address,omitempty"` 22 | PrivateIPv4Addresses []net.IP `json:"PrivateIPv4Addresses,omitempty"` 23 | IPv6Addresses []net.IP `json:"IPv6Addresses,omitempty"` 24 | } 25 | 26 | // ElasticNetworkInterfaceFromNetworkPoint extracts the ElasticNetworkInterface from the lineage of the specified network point. 27 | func ElasticNetworkInterfaceFromNetworkPoint(point reach.NetworkPoint, rc *reach.ResourceCollection) *ElasticNetworkInterface { 28 | for _, ancestor := range point.Lineage { // assumes there will only be one ENI among the ancestors 29 | if ancestor.Domain == ResourceDomainAWS && ancestor.Kind == ResourceKindElasticNetworkInterface { 30 | eni := rc.Get(ancestor).Properties.(ElasticNetworkInterface) 31 | return &eni 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | // ToResource returns the elastic network interface converted to a generalized Reach resource. 39 | func (eni ElasticNetworkInterface) ToResource() reach.Resource { 40 | return reach.Resource{ 41 | Kind: ResourceKindElasticNetworkInterface, 42 | Properties: eni, 43 | } 44 | } 45 | 46 | // ToResourceReference returns a resource reference to uniquely identify the elastic network interface. 47 | func (eni ElasticNetworkInterface) ToResourceReference() reach.ResourceReference { 48 | return reach.ResourceReference{ 49 | Domain: ResourceDomainAWS, 50 | Kind: ResourceKindElasticNetworkInterface, 51 | ID: eni.ID, 52 | } 53 | } 54 | 55 | // Dependencies returns a collection of the elastic network interface's resource dependencies. 56 | func (eni ElasticNetworkInterface) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 57 | rc := reach.NewResourceCollection() 58 | 59 | subnet, err := provider.Subnet(eni.SubnetID) 60 | if err != nil { 61 | return nil, err 62 | } 63 | rc.Put(reach.ResourceReference{ 64 | Domain: ResourceDomainAWS, 65 | Kind: ResourceKindSubnet, 66 | ID: subnet.ID, 67 | }, subnet.ToResource()) 68 | 69 | subnetDependencies, err := subnet.Dependencies(provider) 70 | if err != nil { 71 | return nil, err 72 | } 73 | rc.Merge(subnetDependencies) 74 | 75 | vpc, err := provider.VPC(eni.VPCID) 76 | if err != nil { 77 | return nil, err 78 | } 79 | rc.Put(reach.ResourceReference{ 80 | Domain: ResourceDomainAWS, 81 | Kind: ResourceKindVPC, 82 | ID: vpc.ID, 83 | }, vpc.ToResource()) 84 | 85 | for _, sgID := range eni.SecurityGroupIDs { 86 | sg, err := provider.SecurityGroup(sgID) 87 | if err != nil { 88 | return nil, err 89 | } 90 | rc.Put(reach.ResourceReference{ 91 | Domain: ResourceDomainAWS, 92 | Kind: ResourceKindSecurityGroup, 93 | ID: sg.ID, 94 | }, sg.ToResource()) 95 | 96 | sgDependencies, err := sg.Dependencies(provider) 97 | if err != nil { 98 | return nil, err 99 | } 100 | rc.Merge(sgDependencies) 101 | } 102 | 103 | return rc, nil 104 | } 105 | 106 | func (eni ElasticNetworkInterface) getNetworkPoints(parent reach.ResourceReference) []reach.NetworkPoint { 107 | var networkPoints []reach.NetworkPoint 108 | 109 | lineage := []reach.ResourceReference{ 110 | eni.ToResourceReference(), 111 | parent, 112 | } 113 | 114 | for _, privateIPv4Address := range eni.PrivateIPv4Addresses { 115 | point := reach.NetworkPoint{ 116 | IPAddress: privateIPv4Address, 117 | Lineage: lineage, 118 | } 119 | 120 | networkPoints = append(networkPoints, point) 121 | } 122 | 123 | if !eni.PublicIPv4Address.Equal(nil) { 124 | networkPoints = append(networkPoints, reach.NetworkPoint{ 125 | IPAddress: eni.PublicIPv4Address, 126 | Lineage: lineage, 127 | }) 128 | } 129 | 130 | for _, ipv6Address := range eni.IPv6Addresses { 131 | point := reach.NetworkPoint{ 132 | IPAddress: ipv6Address, 133 | Lineage: lineage, 134 | } 135 | 136 | networkPoints = append(networkPoints, point) 137 | } 138 | 139 | return networkPoints 140 | } 141 | 142 | // Name returns the elastic network interface's ID, and, if available, its name tag value. 143 | func (eni ElasticNetworkInterface) Name() string { 144 | if name := strings.TrimSpace(eni.NameTag); name != "" { 145 | return fmt.Sprintf("\"%s\" (%s)", name, eni.ID) 146 | } 147 | return eni.ID 148 | } 149 | -------------------------------------------------------------------------------- /reach/aws/explainer.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/luhring/reach/reach" 10 | "github.com/luhring/reach/reach/helper" 11 | ) 12 | 13 | const formatResourceMissing = "unable to explain analysis for network point: resource missing from collection: %s" 14 | 15 | // Explainer explains an analysis with respect to AWS. 16 | type Explainer struct { 17 | analysis reach.Analysis 18 | } 19 | 20 | // NewExplainer creates a new AWS-specific explainer. 21 | func NewExplainer(analysis reach.Analysis) *Explainer { 22 | return &Explainer{ 23 | analysis: analysis, 24 | } 25 | } 26 | 27 | // NetworkPoint explains the analysis component for the specified network point. 28 | func (ex *Explainer) NetworkPoint(point reach.NetworkPoint, p reach.Perspective) string { 29 | var outputItems []string 30 | 31 | if f, _ := getInstanceStateFactor(point.Factors); f != nil { 32 | outputItems = append(outputItems, ex.InstanceState(*f)) 33 | } 34 | 35 | if f, _ := getSecurityGroupRulesFactor(point.Factors); f != nil { 36 | outputItems = append(outputItems, ex.SecurityGroupRules(*f, p)) 37 | } 38 | 39 | if f, _ := getNetworkACLRulesFactor(point.Factors); f != nil { 40 | outputItems = append(outputItems, ex.NetworkACLRules(*f, p)) 41 | } 42 | 43 | return strings.Join(outputItems, "\n") 44 | } 45 | 46 | // InstanceState explains the analysis component for the specified instance state factor. 47 | func (ex *Explainer) InstanceState(factor reach.Factor) string { 48 | var outputItems []string 49 | 50 | ec2instanceRef := ex.analysis.Resources.Get(factor.Resource) 51 | if ec2instanceRef == nil { 52 | return fmt.Sprintf(formatResourceMissing, factor.Resource) 53 | } 54 | 55 | ec2Instance := ec2instanceRef.Properties.(EC2Instance) 56 | outputItems = append(outputItems, helper.Bold("instance state:")) 57 | outputItems = append(outputItems, helper.Indent(fmt.Sprintf("\"%s\"", ec2Instance.State), 2)) 58 | outputItems = append(outputItems, "") 59 | outputItems = append(outputItems, helper.Indent("network traffic allowed based on instance state:", 2)) 60 | outputItems = append(outputItems, helper.Indent(factor.Traffic.ColorString(), 4)) 61 | 62 | return strings.Join(outputItems, "\n") 63 | } 64 | 65 | // SecurityGroupRules explains the analysis component for the specified security group rules factor. 66 | func (ex *Explainer) SecurityGroupRules(factor reach.Factor, p reach.Perspective) string { 67 | var outputItems []string 68 | header := fmt.Sprintf( 69 | "%s (including only rules from %s that match %s):", 70 | helper.Bold("security group rules"), 71 | p.SelfRole, 72 | p.OtherRole, 73 | ) 74 | outputItems = append(outputItems, header) 75 | 76 | props := factor.Properties.(securityGroupRulesFactor) 77 | 78 | var bodyItems []string 79 | 80 | if rules := props.RuleComponents; len(rules) == 0 { 81 | bodyItems = append(bodyItems, "no rules that apply to analysis\n") 82 | } else { 83 | var ruleViewModels []securityGroupRuleExplanationViewModel 84 | 85 | for _, rule := range rules { 86 | sgRef := ex.analysis.Resources.Get(rule.SecurityGroup) 87 | if sgRef == nil { 88 | log.Fatalf(formatResourceMissing, rule.SecurityGroup) 89 | } 90 | 91 | sg := sgRef.Properties.(SecurityGroup) 92 | originalRule, err := sg.rule(rule.RuleDirection, rule.RuleIndex) 93 | if err != nil { 94 | log.Fatalf(err.Error()) 95 | } 96 | 97 | var inclusionReason string 98 | 99 | switch rule.Match.Basis { 100 | case securityGroupRuleMatchBasisSGRef: 101 | inclusionReason = fmt.Sprintf( 102 | "This rule specifies a security group \"%s\" that is attached to the %s's network interface.", 103 | rule.Match.Requirement, 104 | p.OtherRole, 105 | ) 106 | case securityGroupRuleMatchBasisIP: 107 | inclusionReason = fmt.Sprintf( 108 | "This rule specifies an IP CIDR block \"%s\" that contains the %s's IP address (%s).", 109 | rule.Match.Requirement, 110 | p.OtherRole, 111 | p.Other.IPAddress, 112 | ) 113 | default: 114 | inclusionReason = fmt.Sprintf("Unknown reason for inclusion. Match basis is '%s'. Please report this.", rule.Match.Basis) 115 | } 116 | 117 | model := securityGroupRuleExplanationViewModel{ 118 | securityGroupName: sg.Name(), 119 | inclusionReason: inclusionReason, 120 | allowedTraffic: originalRule.TrafficContent.String(), 121 | } 122 | 123 | ruleViewModels = append(ruleViewModels, model) 124 | } 125 | 126 | sort.Slice(ruleViewModels, func(i, j int) bool { 127 | return sort.StringsAreSorted([]string{ 128 | ruleViewModels[i].securityGroupName, 129 | ruleViewModels[j].securityGroupName, 130 | }) 131 | }) 132 | 133 | var addedSecurityGroupNames []string 134 | 135 | rulesContent := "" 136 | 137 | for _, ruleViewModel := range ruleViewModels { 138 | securityGroupNameIsNew := true 139 | 140 | for _, addedName := range addedSecurityGroupNames { 141 | if ruleViewModel.securityGroupName == addedName { 142 | securityGroupNameIsNew = false 143 | break 144 | } 145 | } 146 | 147 | if securityGroupNameIsNew { 148 | if rulesContent != "" { 149 | bodyItems = append(bodyItems, rulesContent) 150 | } 151 | 152 | bodyItems = append(bodyItems, fmt.Sprintf("%s:", ruleViewModel.securityGroupName)) 153 | addedSecurityGroupNames = append(addedSecurityGroupNames, ruleViewModel.securityGroupName) 154 | rulesContent = "" 155 | } 156 | 157 | rulesContent += helper.Indent(ruleViewModel.String(), 2) 158 | } 159 | 160 | if rulesContent != "" { 161 | bodyItems = append(bodyItems, rulesContent) 162 | } 163 | } 164 | 165 | bodyItems = append(bodyItems, "network traffic allowed based on security group rules:") 166 | bodyItems = append(bodyItems, helper.Indent(factor.Traffic.ColorString(), 2)) 167 | 168 | body := strings.Join(bodyItems, "\n") 169 | outputItems = append(outputItems, helper.Indent(body, 2)) 170 | 171 | return strings.Join(outputItems, "\n") 172 | } 173 | 174 | // NetworkACLRules explains the analysis component for the specified network ACL rules factor. 175 | func (ex *Explainer) NetworkACLRules(factor reach.Factor, p reach.Perspective) string { 176 | var outputItems []string 177 | header := fmt.Sprintf( 178 | "%s (including only rules from %s that match %s):", 179 | helper.Bold("network ACL rules"), 180 | p.SelfRole, 181 | p.OtherRole, 182 | ) 183 | outputItems = append(outputItems, header) 184 | 185 | props := factor.Properties.(networkACLRulesFactor) 186 | 187 | var bodyItems []string 188 | 189 | if rules := props.RuleComponentsForwardDirection; len(rules) == 0 { 190 | bodyItems = append(bodyItems, "no rules that apply to analysis\n") 191 | } else { 192 | // forward direction 193 | forwardViewModels := networkACLRuleComponentsToViewModels(props.RuleComponentsForwardDirection, p) 194 | 195 | var forwardExplanation string 196 | for _, model := range forwardViewModels { 197 | forwardExplanation += model.String() 198 | } 199 | bodyItems = append(bodyItems, forwardExplanation) 200 | 201 | // return direction 202 | returnHeader := "rules that affect network traffic returning from destination to source:\n" 203 | bodyItems = append(bodyItems, returnHeader) 204 | 205 | returnViewModels := networkACLRuleComponentsToViewModels(props.RuleComponentsReturnDirection, p) 206 | 207 | var returnExplanation string 208 | for _, model := range returnViewModels { 209 | returnExplanation += model.String() 210 | } 211 | bodyItems = append(bodyItems, returnExplanation) 212 | } 213 | 214 | bodyItems = append(bodyItems, "network traffic allowed based on network ACL rules:") 215 | bodyItems = append(bodyItems, helper.Indent(factor.Traffic.ColorString(), 2)) 216 | 217 | bodyItems = append(bodyItems, "return network traffic allowed based on network ACL rules:") 218 | bodyItems = append(bodyItems, helper.Indent(factor.ReturnTraffic.String(), 2)) 219 | 220 | body := strings.Join(bodyItems, "\n") 221 | outputItems = append(outputItems, helper.Indent(body, 2)) 222 | 223 | return strings.Join(outputItems, "\n") 224 | } 225 | 226 | // CheckBothInAWS returns a boolean indicating whether both network points in a network vector are AWS resources. 227 | func (ex Explainer) CheckBothInAWS(v reach.NetworkVector) bool { 228 | return IsUsedByNetworkPoint(v.Source) && IsUsedByNetworkPoint(v.Destination) 229 | } 230 | 231 | // CheckBothInSameVPC returns a boolean indicating whether both network points in a network vector reside in the same AWS VPC. 232 | func (ex Explainer) CheckBothInSameVPC(v reach.NetworkVector) bool { 233 | sourceENI, destinationENI, err := GetENIsFromVector(v, ex.analysis.Resources) 234 | if err != nil { 235 | return false 236 | } 237 | 238 | return sourceENI.VPCID == destinationENI.VPCID 239 | } 240 | 241 | // CheckBothInSameSubnet returns a boolean indicating whether both network points in a network vector reside in the same AWS subnet. 242 | func (ex Explainer) CheckBothInSameSubnet(v reach.NetworkVector) bool { 243 | sourceENI, destinationENI, err := GetENIsFromVector(v, ex.analysis.Resources) 244 | if err != nil { 245 | return false 246 | } 247 | 248 | return sourceENI.SubnetID == destinationENI.SubnetID 249 | } 250 | -------------------------------------------------------------------------------- /reach/aws/factors.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | func getInstanceStateFactor(factors []reach.Factor) (*reach.Factor, error) { 10 | for _, factor := range factors { 11 | if factor.Kind == FactorKindInstanceState { 12 | return &factor, nil 13 | } 14 | } 15 | 16 | return nil, errors.New("no instance state factor found") 17 | } 18 | 19 | func getSecurityGroupRulesFactor(factors []reach.Factor) (*reach.Factor, error) { 20 | for _, factor := range factors { 21 | if factor.Kind == FactorKindSecurityGroupRules { 22 | return &factor, nil 23 | } 24 | } 25 | 26 | return nil, errors.New("no security group rules factor found") 27 | } 28 | 29 | func getNetworkACLRulesFactor(factors []reach.Factor) (*reach.Factor, error) { 30 | for _, factor := range factors { 31 | if factor.Kind == FactorKindNetworkACLRules { 32 | return &factor, nil 33 | } 34 | } 35 | 36 | return nil, errors.New("no network ACL rules factor found") 37 | } 38 | -------------------------------------------------------------------------------- /reach/aws/find_ec2_instance_id.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // FindEC2InstanceID looks up the instance ID for an EC2 instance using a given resource provider (e.g. an AWS API client) based on the specified search text. The search text can match the entire value or beginning substring for an instance's ID or name tag value, as long as the text matches exactly one EC2 instance. 9 | func FindEC2InstanceID(searchText string, provider ResourceProvider) (string, error) { 10 | instances, err := provider.AllEC2Instances() 11 | if err != nil { 12 | return "", err 13 | } 14 | 15 | var matchesOnID []int 16 | var matchesOnName []int 17 | 18 | // discover what matches exist... and an exact match on instance ID can return early. 19 | 20 | for i, instance := range instances { 21 | if isInstanceID(searchText) { 22 | if strings.EqualFold(searchText, instance.ID) { // exact match -- instance ID 23 | // no need to examine any more instances 24 | return instance.ID, nil 25 | } 26 | 27 | if strings.HasPrefix(instance.ID, searchText) { // partial match -- instance ID 28 | matchesOnID = append(matchesOnID, i) 29 | } 30 | } 31 | 32 | if strings.HasPrefix(instance.NameTag, searchText) { // partial or exact match -- instance name 33 | matchesOnName = append(matchesOnName, i) 34 | } 35 | } 36 | 37 | // first priority goes to partial match on instance ID 38 | 39 | if matchesOnID != nil { 40 | if len(matchesOnID) == 1 { 41 | return instances[matchesOnID[0]].ID, nil 42 | } 43 | 44 | if len(matchesOnID) >= 2 { 45 | var ids []string 46 | 47 | for _, matchIdx := range matchesOnID { 48 | ids = append(ids, instances[matchIdx].ID) 49 | } 50 | 51 | matches := strings.Join(ids, ", ") 52 | return "", fmt.Errorf("error: search text matches multiple EC2 instances' IDs (matches for search text '%s': %s)", searchText, matches) 53 | } 54 | } 55 | 56 | // next, we hope for a match against name by only one instance (partial or exact) 57 | 58 | if matchesOnName != nil { 59 | if len(matchesOnName) == 1 { 60 | return instances[matchesOnName[0]].ID, nil 61 | } 62 | 63 | if len(matchesOnName) >= 2 { 64 | // prepare helpful error text 65 | var matchedInstances []string 66 | 67 | for _, matchIdx := range matchesOnID { 68 | name := instances[matchIdx].NameTag 69 | id := instances[matchIdx].ID 70 | 71 | matchedInstances = append(matchedInstances, fmt.Sprintf("'%s' (%s)", name, id)) 72 | } 73 | 74 | matches := strings.Join(matchedInstances, ", ") 75 | return "", fmt.Errorf("error: search text matches multiple EC2 instances' name tags (matches for search text '%s': %s)", searchText, matches) 76 | } 77 | } 78 | 79 | return "", fmt.Errorf("error: search text '%s' did not match the ID or name tag of any EC2 instances", searchText) 80 | } 81 | 82 | func isInstanceID(text string) bool { 83 | const instanceIDPrefix = "i-" 84 | return len(text) >= 3 && strings.HasPrefix(text, instanceIDPrefix) 85 | } 86 | -------------------------------------------------------------------------------- /reach/aws/find_ec2_instance_id_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/luhring/reach/reach" 8 | acc "github.com/luhring/reach/reach/acceptance" 9 | ) 10 | 11 | func TestFindEC2InstanceID(t *testing.T) { 12 | acc.Check(t) 13 | 14 | cases := []struct { 15 | searchText string 16 | expectedID string 17 | expectedError error 18 | }{ 19 | { 20 | searchText: "abc", 21 | expectedError: nil, 22 | expectedID: "i-0a93117c7575b6d54", 23 | }, 24 | { 25 | searchText: "def", 26 | expectedError: nil, 27 | expectedID: "i-0136d3233f0ef1924", 28 | }, 29 | // { // TODO: Add back negative cases when aws_manager implementation is replaced 30 | // searchText: "ghi", 31 | // expectedError: nil, 32 | // expectedID: "", 33 | // }, 34 | } 35 | 36 | for _, tc := range cases { 37 | t.Run(tc.searchText, func(t *testing.T) { 38 | id, err := FindEC2InstanceID(tc.searchText, nil) 39 | 40 | if tc.expectedID != id { 41 | reach.DiffErrorf(t, "id", tc.expectedID, id) 42 | } 43 | 44 | if !reflect.DeepEqual(tc.expectedError, err) { 45 | reach.DiffErrorf(t, "err", tc.expectedError, err) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /reach/aws/instance_state_factor.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/luhring/reach/reach" 5 | ) 6 | 7 | // FactorKindInstanceState specifies the unique name for the EC2 instance state of factor. 8 | const FactorKindInstanceState = "InstanceState" 9 | 10 | func (i EC2Instance) newInstanceStateFactor() reach.Factor { 11 | var traffic reach.TrafficContent 12 | var returnTraffic reach.TrafficContent 13 | 14 | if i.isRunning() { 15 | traffic = reach.NewTrafficContentForAllTraffic() 16 | returnTraffic = reach.NewTrafficContentForAllTraffic() 17 | } else { 18 | traffic = reach.NewTrafficContentForNoTraffic() 19 | returnTraffic = reach.NewTrafficContentForNoTraffic() 20 | } 21 | 22 | return reach.Factor{ 23 | Kind: FactorKindInstanceState, 24 | Resource: i.ToResourceReference(), 25 | Traffic: traffic, 26 | ReturnTraffic: returnTraffic, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reach/aws/is_used_by.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // IsUsedByNetworkPoint returns a boolean indicating whether or not the specified network point contains an AWS-specific kind of resource. 6 | func IsUsedByNetworkPoint(point reach.NetworkPoint) bool { 7 | return containsAWSResource(point.Lineage) 8 | } 9 | 10 | func containsAWSResource(refs []reach.ResourceReference) bool { 11 | for _, ref := range refs { 12 | if ref.Domain == ResourceDomainAWS { 13 | return true 14 | } 15 | } 16 | 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /reach/aws/lineage.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | // GetENIFromLineage returns the ElasticNetworkInterface from the given lineage. 10 | func GetENIFromLineage(lineage []reach.ResourceReference, collection *reach.ResourceCollection) (*ElasticNetworkInterface, error) { 11 | const errPrefix = "unable to get ElasticNetworkInterface from lineage" 12 | 13 | for _, ref := range lineage { 14 | if ref.Domain == ResourceDomainAWS && ref.Kind == ResourceKindElasticNetworkInterface { 15 | eniResource := collection.Get(ref) 16 | if eniResource == nil { 17 | return nil, fmt.Errorf("%s: no resource found in resource collection for reference: %s", errPrefix, ref) 18 | } 19 | 20 | eni := eniResource.Properties.(ElasticNetworkInterface) 21 | return &eni, nil 22 | } 23 | } 24 | 25 | return nil, fmt.Errorf("%s: lineage does not contain an ElasticNetworkInterface", errPrefix) 26 | } 27 | 28 | // GetENIsFromVector returns the ElasticNetworkInterfaces from the specified network vector. 29 | func GetENIsFromVector(v reach.NetworkVector, collection *reach.ResourceCollection) (*ElasticNetworkInterface, *ElasticNetworkInterface, error) { 30 | sourceENI, err := GetENIFromLineage(v.Source.Lineage, collection) 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | 35 | destinationENI, err := GetENIFromLineage(v.Destination.Lineage, collection) 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | 40 | return sourceENI, destinationENI, nil 41 | } 42 | 43 | // GetEC2InstanceFromLineage returns the EC2 instance from the given lineage. 44 | func GetEC2InstanceFromLineage(lineage []reach.ResourceReference, collection *reach.ResourceCollection) (*EC2Instance, error) { 45 | const errPrefix = "unable to get EC2Instance from lineage" 46 | 47 | for _, ref := range lineage { 48 | if ref.Domain == ResourceDomainAWS && ref.Kind == ResourceKindEC2Instance { 49 | ec2InstanceResource := collection.Get(ref) 50 | if ec2InstanceResource == nil { 51 | return nil, fmt.Errorf("%s: no resource found in resource collection for reference: %s", errPrefix, ref) 52 | } 53 | 54 | ec2Instance := ec2InstanceResource.Properties.(EC2Instance) 55 | return &ec2Instance, nil 56 | } 57 | } 58 | 59 | return nil, fmt.Errorf("%s: lineage does not contain an EC2Instance", errPrefix) 60 | } 61 | -------------------------------------------------------------------------------- /reach/aws/network_acl.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // ResourceKindNetworkACL specifies the unique name for the network ACL kind of resource. 6 | const ResourceKindNetworkACL = "NetworkACL" 7 | 8 | // A NetworkACL resource representation. 9 | type NetworkACL struct { 10 | ID string 11 | InboundRules []NetworkACLRule 12 | OutboundRules []NetworkACLRule 13 | } 14 | 15 | // ToResource returns the network ACL converted to a generalized Reach resource. 16 | func (nacl NetworkACL) ToResource() reach.Resource { 17 | return reach.Resource{ 18 | Kind: ResourceKindNetworkACL, 19 | Properties: nacl, 20 | } 21 | } 22 | 23 | // ToResourceReference returns a resource reference to uniquely identify the network ACL. 24 | func (nacl NetworkACL) ToResourceReference() reach.ResourceReference { 25 | return reach.ResourceReference{ 26 | Domain: ResourceDomainAWS, 27 | Kind: ResourceKindNetworkACL, 28 | ID: nacl.ID, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rule.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "encoding/json" 5 | "net" 6 | 7 | "github.com/luhring/reach/reach" 8 | ) 9 | 10 | // A NetworkACLRuleAction is the action specified by a network ACL rule -- either allow or deny. 11 | type NetworkACLRuleAction int 12 | 13 | // The allowed actions for a network ACL rule. 14 | const ( 15 | NetworkACLRuleActionDeny NetworkACLRuleAction = iota 16 | NetworkACLRuleActionAllow 17 | ) 18 | 19 | // String returns the string representation of the NetworkACLRuleAction. 20 | func (action NetworkACLRuleAction) String() string { 21 | switch action { 22 | case NetworkACLRuleActionDeny: 23 | return "deny" 24 | case NetworkACLRuleActionAllow: 25 | return "allow" 26 | default: 27 | return "[unknown action]" 28 | } 29 | } 30 | 31 | // MarshalJSON returns the JSON representation of the NetworkACLRuleAction. 32 | func (action NetworkACLRuleAction) MarshalJSON() ([]byte, error) { 33 | return json.Marshal(action.String()) 34 | } 35 | 36 | // An NetworkACLRule resource representation. 37 | type NetworkACLRule struct { 38 | Number int64 39 | TrafficContent reach.TrafficContent 40 | TargetIPNetwork *net.IPNet 41 | Action NetworkACLRuleAction 42 | } 43 | 44 | // Allows returns a boolean indicating if the rule is allowing traffic. 45 | func (r NetworkACLRule) Allows() bool { 46 | return r.Action == NetworkACLRuleActionAllow 47 | } 48 | 49 | // Denies returns a boolean indicating if the rule is denying traffic. 50 | func (r NetworkACLRule) Denies() bool { 51 | return r.Action == NetworkACLRuleActionDeny 52 | } 53 | 54 | func (r NetworkACLRule) matchByIP(ip net.IP) *networkACLRuleMatch { 55 | if r.TargetIPNetwork.Contains(ip) { 56 | return &networkACLRuleMatch{ 57 | Requirement: *r.TargetIPNetwork, 58 | Value: ip, 59 | } 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rule_direction.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type networkACLRuleDirection string 4 | 5 | const networkACLRuleDirectionInbound networkACLRuleDirection = "inbound" 6 | const networkACLRuleDirectionOutbound networkACLRuleDirection = "outbound" 7 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rule_explanation_view_model.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/luhring/reach/reach" 7 | "github.com/luhring/reach/reach/helper" 8 | ) 9 | 10 | type networkACLRuleExplanationViewModel struct { 11 | ruleNumber int64 12 | allowedTraffic string 13 | inclusionReason string 14 | } 15 | 16 | func newNetworkACLRuleExplanationViewModel(rule networkACLRulesFactorComponent, p reach.Perspective) networkACLRuleExplanationViewModel { 17 | inclusionReason := fmt.Sprintf( 18 | "This rule specifies an IP CIDR block \"%s\" that contains the %s's IP address (%s).", 19 | rule.Match.Requirement.String(), 20 | p.OtherRole, 21 | p.Other.IPAddress, 22 | ) 23 | 24 | return networkACLRuleExplanationViewModel{ 25 | ruleNumber: rule.RuleNumber, 26 | allowedTraffic: rule.Traffic.String(), 27 | inclusionReason: inclusionReason, 28 | } 29 | } 30 | 31 | func (model networkACLRuleExplanationViewModel) String() string { 32 | output := fmt.Sprintf("- rule # %d\n", model.ruleNumber) 33 | 34 | allowedTrafficHeader := "network traffic allowed:" 35 | allowedTrafficSection := fmt.Sprintf("%s\n%s", allowedTrafficHeader, helper.Indent(model.allowedTraffic, 2)) 36 | output += helper.Indent(allowedTrafficSection, 4) 37 | 38 | inclusionReasonHeader := "reason for inclusion:" 39 | inclusionReasonSection := fmt.Sprintf("%s\n%s\n", inclusionReasonHeader, helper.Indent(model.inclusionReason, 2)) 40 | output += helper.Indent(inclusionReasonSection, 4) 41 | 42 | return output 43 | } 44 | 45 | func networkACLRuleComponentsToViewModels(rules []networkACLRulesFactorComponent, p reach.Perspective) []networkACLRuleExplanationViewModel { 46 | var models []networkACLRuleExplanationViewModel 47 | 48 | for _, rule := range rules { 49 | model := newNetworkACLRuleExplanationViewModel(rule, p) 50 | models = append(models, model) 51 | } 52 | 53 | return models 54 | } 55 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rule_match.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "net" 4 | 5 | type networkACLRuleMatch struct { 6 | Requirement net.IPNet 7 | Value net.IP 8 | } 9 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rules_factor.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/luhring/reach/reach" 8 | ) 9 | 10 | // FactorKindNetworkACLRules specifies the unique name for the network ACL rules kind of factor. 11 | const FactorKindNetworkACLRules = "NetworkACLRules" 12 | 13 | const newNetworkACLRulesFactorErrFmt = "unable to compute network ACL rules factor: %v" 14 | 15 | type networkACLRulesFactor struct { 16 | RuleComponentsForwardDirection []networkACLRulesFactorComponent 17 | RuleComponentsReturnDirection []networkACLRulesFactorComponent 18 | } 19 | 20 | func (eni ElasticNetworkInterface) newNetworkACLRulesFactor( 21 | rc *reach.ResourceCollection, 22 | p reach.Perspective, 23 | awsP perspective, 24 | targetENI *ElasticNetworkInterface, 25 | ) (*reach.Factor, error) { 26 | subnetResource := rc.Get(reach.ResourceReference{ 27 | Domain: ResourceDomainAWS, 28 | Kind: ResourceKindSubnet, 29 | ID: eni.SubnetID, 30 | }) 31 | if subnetResource == nil { 32 | return nil, fmt.Errorf("couldn't find subnet: %s", eni.SubnetID) 33 | } 34 | subnet := subnetResource.Properties.(Subnet) 35 | 36 | ref := reach.ResourceReference{ 37 | Domain: ResourceDomainAWS, 38 | Kind: ResourceKindNetworkACL, 39 | ID: subnet.NetworkACLID, 40 | } 41 | 42 | networkACLResource := rc.Get(ref) 43 | if networkACLResource == nil { 44 | return nil, fmt.Errorf("couldn't find network ACL: %s", subnet.NetworkACLID) 45 | } 46 | networkACL := networkACLResource.Properties.(NetworkACL) 47 | 48 | forwardTraffic, forwardComponents, err := networkACL.effectOnForwardTraffic(p, awsP) 49 | if err != nil { 50 | return nil, fmt.Errorf(newNetworkACLRulesFactorErrFmt, err) 51 | } 52 | 53 | returnTraffic, returnComponents, err := networkACL.effectOnReturnTraffic(p, awsP) 54 | if err != nil { 55 | return nil, fmt.Errorf(newNetworkACLRulesFactorErrFmt, err) 56 | } 57 | 58 | props := networkACLRulesFactor{ 59 | RuleComponentsForwardDirection: forwardComponents, 60 | RuleComponentsReturnDirection: returnComponents, 61 | } 62 | 63 | return &reach.Factor{ 64 | Kind: FactorKindNetworkACLRules, 65 | Resource: eni.ToResourceReference(), 66 | Traffic: forwardTraffic, 67 | ReturnTraffic: returnTraffic, 68 | Properties: props, 69 | }, nil 70 | } 71 | 72 | func (nacl NetworkACL) effectOnForwardTraffic(p reach.Perspective, awsP perspective) (reach.TrafficContent, []networkACLRulesFactorComponent, error) { 73 | return nacl.factorComponents(awsP.networkACLRuleDirectionForForwardTraffic, p, awsP) 74 | } 75 | 76 | func (nacl NetworkACL) effectOnReturnTraffic(p reach.Perspective, awsP perspective) (reach.TrafficContent, []networkACLRulesFactorComponent, error) { 77 | return nacl.factorComponents(awsP.networkACLRuleDirectionForReturnTraffic, p, awsP) 78 | } 79 | 80 | func (nacl NetworkACL) rulesForDirection(direction networkACLRuleDirection) []NetworkACLRule { 81 | if direction == networkACLRuleDirectionOutbound { 82 | return nacl.OutboundRules 83 | } 84 | 85 | return nacl.InboundRules 86 | } 87 | 88 | func (nacl NetworkACL) factorComponents(direction networkACLRuleDirection, p reach.Perspective, awsP perspective) (reach.TrafficContent, []networkACLRulesFactorComponent, error) { 89 | rules := nacl.rulesForDirection(direction) 90 | 91 | sort.Slice(rules, func(i, j int) bool { 92 | return rules[i].Number < rules[j].Number 93 | }) 94 | 95 | var trafficContentSegments []reach.TrafficContent 96 | var ruleComponents []networkACLRulesFactorComponent 97 | decidedTraffic := reach.NewTrafficContentForNoTraffic() 98 | 99 | for _, rule := range rules { 100 | // Make sure rule matches 101 | match := rule.matchByIP(p.Other.IPAddress) 102 | if match == nil { 103 | continue // this rule doesn't match 104 | } 105 | 106 | if rule.Allows() { 107 | // Determine what subset of rule traffic affects outcome 108 | effectiveTraffic, err := rule.TrafficContent.Subtract(decidedTraffic) 109 | if err != nil { 110 | return reach.TrafficContent{}, nil, fmt.Errorf(newNetworkACLRulesFactorErrFmt, err) 111 | } 112 | 113 | // add the allowed traffic to the trafficContentSegments 114 | trafficContentSegments = append(trafficContentSegments, effectiveTraffic) 115 | 116 | // add to ruleComponents for the explanation 117 | ruleComponents = append(ruleComponents, networkACLRulesFactorComponent{ 118 | NetworkACL: nacl.ToResourceReference(), 119 | RuleDirection: direction, 120 | RuleNumber: rule.Number, 121 | Match: *match, 122 | Traffic: effectiveTraffic, 123 | }) 124 | } 125 | 126 | var err error 127 | decidedTraffic, err = reach.NewTrafficContentFromMergingMultiple( 128 | []reach.TrafficContent{ 129 | decidedTraffic, 130 | rule.TrafficContent, 131 | }, 132 | ) 133 | if err != nil { 134 | return reach.TrafficContent{}, nil, fmt.Errorf(newNetworkACLRulesFactorErrFmt, err) 135 | } 136 | } 137 | 138 | traffic, err := reach.NewTrafficContentFromMergingMultiple(trafficContentSegments) 139 | if err != nil { 140 | return reach.TrafficContent{}, nil, fmt.Errorf(newNetworkACLRulesFactorErrFmt, err) 141 | } 142 | 143 | return traffic, ruleComponents, nil 144 | } 145 | -------------------------------------------------------------------------------- /reach/aws/network_acl_rules_factor_component.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | type networkACLRulesFactorComponent struct { 6 | NetworkACL reach.ResourceReference 7 | RuleDirection networkACLRuleDirection 8 | RuleNumber int64 9 | Match networkACLRuleMatch 10 | Traffic reach.TrafficContent 11 | } 12 | -------------------------------------------------------------------------------- /reach/aws/network_interface_attachment.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // A NetworkInterfaceAttachment resource representation. 6 | type NetworkInterfaceAttachment struct { 7 | ID string 8 | ElasticNetworkInterfaceID string 9 | DeviceIndex int64 // e.g. 0 for "eth0" 10 | } 11 | 12 | // Dependencies returns a collection of the network interface attachment's resource dependencies. 13 | func (attachment NetworkInterfaceAttachment) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 14 | rc := reach.NewResourceCollection() 15 | 16 | eni, err := provider.ElasticNetworkInterface(attachment.ElasticNetworkInterfaceID) 17 | if err != nil { 18 | return nil, err 19 | } 20 | rc.Put(reach.ResourceReference{ 21 | Domain: ResourceDomainAWS, 22 | Kind: ResourceKindElasticNetworkInterface, 23 | ID: eni.ID, 24 | }, eni.ToResource()) 25 | 26 | eniDependencies, err := eni.Dependencies(provider) 27 | if err != nil { 28 | return nil, err 29 | } 30 | rc.Merge(eniDependencies) 31 | 32 | return rc, nil 33 | } 34 | -------------------------------------------------------------------------------- /reach/aws/new_subject.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/luhring/reach/reach" 5 | ) 6 | 7 | // NewSubject looks up an AWS resource using the given provider and returns it as a new subject. 8 | func NewSubject(identifier string, provider ResourceProvider) (*reach.Subject, error) { 9 | // We'll assume the identifier refers to an EC2 instance, even if it doesn't begin with 'i-'. 10 | // Later, we might use this string to recognize different kinds of AWS resources. 11 | ec2InstanceID, err := FindEC2InstanceID(identifier, provider) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | subject, err := NewEC2InstanceSubject(ec2InstanceID, reach.SubjectRoleNone) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return subject, nil 22 | } 23 | -------------------------------------------------------------------------------- /reach/aws/perspective.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type perspective struct { 4 | securityGroupRules func(sg SecurityGroup) []SecurityGroupRule 5 | securityGroupRuleDirection securityGroupRuleDirection 6 | networkACLRulesForForwardTraffic func(nacl NetworkACL) []NetworkACLRule 7 | networkACLRuleDirectionForForwardTraffic networkACLRuleDirection 8 | networkACLRulesForReturnTraffic func(nacl NetworkACL) []NetworkACLRule 9 | networkACLRuleDirectionForReturnTraffic networkACLRuleDirection 10 | } 11 | 12 | func newPerspectiveSourceOriented() perspective { 13 | return perspective{ 14 | securityGroupRules: func(sg SecurityGroup) []SecurityGroupRule { 15 | return sg.OutboundRules 16 | }, 17 | securityGroupRuleDirection: securityGroupRuleDirectionOutbound, 18 | networkACLRulesForForwardTraffic: func(nacl NetworkACL) []NetworkACLRule { 19 | return nacl.OutboundRules 20 | }, 21 | networkACLRuleDirectionForForwardTraffic: networkACLRuleDirectionOutbound, 22 | networkACLRulesForReturnTraffic: func(nacl NetworkACL) []NetworkACLRule { 23 | return nacl.InboundRules 24 | }, 25 | networkACLRuleDirectionForReturnTraffic: networkACLRuleDirectionInbound, 26 | } 27 | } 28 | 29 | func newPerspectiveDestinationOriented() perspective { 30 | return perspective{ 31 | securityGroupRules: func(sg SecurityGroup) []SecurityGroupRule { 32 | return sg.InboundRules 33 | }, 34 | securityGroupRuleDirection: securityGroupRuleDirectionInbound, 35 | networkACLRulesForForwardTraffic: func(nacl NetworkACL) []NetworkACLRule { 36 | return nacl.InboundRules 37 | }, 38 | networkACLRuleDirectionForForwardTraffic: networkACLRuleDirectionInbound, 39 | networkACLRulesForReturnTraffic: func(nacl NetworkACL) []NetworkACLRule { 40 | return nacl.OutboundRules 41 | }, 42 | networkACLRuleDirectionForReturnTraffic: networkACLRuleDirectionOutbound, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /reach/aws/resource_provider.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | // The ResourceProvider interface wraps all of the necessary methods for accessing AWS-specific resources. 4 | type ResourceProvider interface { 5 | AllEC2Instances() ([]EC2Instance, error) 6 | EC2Instance(id string) (*EC2Instance, error) 7 | ElasticNetworkInterface(id string) (*ElasticNetworkInterface, error) 8 | NetworkACL(id string) (*NetworkACL, error) 9 | RouteTable(id string) (*RouteTable, error) 10 | SecurityGroup(id string) (*SecurityGroup, error) 11 | SecurityGroupReference(id, accountID string) (*SecurityGroupReference, error) 12 | Subnet(id string) (*Subnet, error) 13 | VPC(id string) (*VPC, error) 14 | } 15 | -------------------------------------------------------------------------------- /reach/aws/route_table.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // ResourceKindRouteTable specifies the unique name for the route table kind of resource. 6 | const ResourceKindRouteTable = "RouteTable" 7 | 8 | // A RouteTable resource representation. 9 | type RouteTable struct { 10 | ID string 11 | VPCID string 12 | Routes []RouteTableRoute 13 | } 14 | 15 | // ToResource returns the route table converted to a generalized Reach resource. 16 | func (rt RouteTable) ToResource() reach.Resource { 17 | return reach.Resource{ 18 | Kind: ResourceKindRouteTable, 19 | Properties: rt, 20 | } 21 | } 22 | 23 | // Dependencies returns a collection of the route table's resource dependencies. 24 | func (rt RouteTable) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 25 | rc := reach.NewResourceCollection() 26 | 27 | vpc, err := provider.VPC(rt.VPCID) 28 | if err != nil { 29 | return nil, err 30 | } 31 | rc.Put(reach.ResourceReference{ 32 | Domain: ResourceDomainAWS, 33 | Kind: ResourceKindVPC, 34 | ID: vpc.ID, 35 | }, vpc.ToResource()) 36 | 37 | // TODO: Figure out dependencies from RouteTableRoute (i.e. route targets) 38 | 39 | return rc, nil 40 | } 41 | -------------------------------------------------------------------------------- /reach/aws/route_table_route.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "net" 4 | 5 | // A RouteTableRoute resource representation. 6 | type RouteTableRoute struct { 7 | Destination *net.IPNet 8 | Target interface{} // TODO: Figure this out -- this is not the normal Reach 'target' 9 | States string 10 | Propagated bool 11 | } 12 | -------------------------------------------------------------------------------- /reach/aws/security_group.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | // ResourceKindSecurityGroup specifies the unique name for the security group kind of resource. 10 | const ResourceKindSecurityGroup = "SecurityGroup" 11 | 12 | // A SecurityGroup resource representation. 13 | type SecurityGroup struct { 14 | ID string 15 | NameTag string 16 | GroupName string 17 | VPCID string 18 | InboundRules []SecurityGroupRule 19 | OutboundRules []SecurityGroupRule 20 | } 21 | 22 | // ToResource returns the security group converted to a generalized Reach resource. 23 | func (sg SecurityGroup) ToResource() reach.Resource { 24 | return reach.Resource{ 25 | Kind: ResourceKindSecurityGroup, 26 | Properties: sg, 27 | } 28 | } 29 | 30 | // Dependencies returns a collection of the security group's resource dependencies. 31 | func (sg SecurityGroup) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 32 | rc := reach.NewResourceCollection() 33 | 34 | vpc, err := provider.VPC(sg.VPCID) 35 | if err != nil { 36 | return nil, err 37 | } 38 | rc.Put(reach.ResourceReference{ 39 | Domain: ResourceDomainAWS, 40 | Kind: ResourceKindVPC, 41 | ID: vpc.ID, 42 | }, vpc.ToResource()) 43 | 44 | allRules := append(sg.InboundRules, sg.OutboundRules...) 45 | 46 | for _, rule := range allRules { 47 | // TODO: sg ref IDs shouldn't be strings, they should be pointers, and this check should be for nil not "" 48 | 49 | if sgRefID := rule.TargetSecurityGroupReferenceID; sgRefID != "" { 50 | sgRef, err := provider.SecurityGroupReference(sgRefID, rule.TargetSecurityGroupReferenceAccountID) 51 | if err != nil { 52 | return nil, err 53 | } 54 | rc.Put(reach.ResourceReference{ 55 | Domain: ResourceDomainAWS, 56 | Kind: ResourceKindSecurityGroupReference, 57 | ID: sgRef.ID, 58 | }, sgRef.ToResource()) 59 | } 60 | } 61 | 62 | return rc, nil 63 | } 64 | 65 | // Name returns the security group's ID, and, if available, its name tag value (or group name). 66 | func (sg SecurityGroup) Name() string { 67 | var name string 68 | 69 | if sg.NameTag != "" { 70 | name = sg.NameTag 71 | } else if sg.GroupName != "" { 72 | name = sg.GroupName 73 | } 74 | 75 | if name != "" { 76 | return fmt.Sprintf("%s (%s)", name, sg.ID) 77 | } 78 | 79 | return sg.ID 80 | } 81 | 82 | func (sg SecurityGroup) rule(direction securityGroupRuleDirection, ruleIndex int) (*SecurityGroupRule, error) { 83 | errNotFound := fmt.Errorf("rule not found for direction '%s' and index '%d'", direction, ruleIndex) 84 | 85 | var rules []SecurityGroupRule 86 | 87 | switch direction { 88 | case securityGroupRuleDirectionInbound: 89 | rules = sg.InboundRules 90 | case securityGroupRuleDirectionOutbound: 91 | rules = sg.OutboundRules 92 | default: 93 | return nil, errNotFound 94 | } 95 | 96 | if ruleIndex < 0 || ruleIndex >= len(rules) { 97 | return nil, errNotFound 98 | } 99 | 100 | return &rules[ruleIndex], nil 101 | } 102 | -------------------------------------------------------------------------------- /reach/aws/security_group_reference.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // ResourceKindSecurityGroupReference specifies the unique name for the security group reference kind of resource. 6 | const ResourceKindSecurityGroupReference = "SecurityGroupReference" 7 | 8 | // A SecurityGroupReference resource representation. A SecurityGroupReference is similar to a SecurityGroup, except it intentionally omits any further dependencies, so as to prevent a dependency cycle when security groups have security group rules that refer to security groups. 9 | type SecurityGroupReference struct { 10 | ID string 11 | AccountID string 12 | NameTag string 13 | GroupName string 14 | } 15 | 16 | // ToResource returns the security group reference converted to a generalized Reach resource. 17 | func (sgRef SecurityGroupReference) ToResource() reach.Resource { 18 | return reach.Resource{ 19 | Kind: ResourceKindSecurityGroupReference, 20 | Properties: sgRef, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /reach/aws/security_group_rule.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | // A SecurityGroupRule resource representation. 10 | type SecurityGroupRule struct { 11 | TrafficContent reach.TrafficContent 12 | TargetSecurityGroupReferenceID string `json:"TargetSecurityGroupReferenceID,omitempty"` 13 | TargetSecurityGroupReferenceAccountID string `json:"TargetSecurityGroupReferenceAccountID,omitempty"` 14 | TargetIPNetworks []*net.IPNet `json:"TargetIPNetworks,omitempty"` 15 | } 16 | 17 | func (rule SecurityGroupRule) matchByIP(ip net.IP) *securityGroupRuleMatch { 18 | for _, network := range rule.TargetIPNetworks { 19 | if network.Contains(ip) { 20 | return &securityGroupRuleMatch{ 21 | Basis: securityGroupRuleMatchBasisIP, 22 | Requirement: network, 23 | Value: ip, 24 | } 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func (rule SecurityGroupRule) matchBySecurityGroup(eni *ElasticNetworkInterface) *securityGroupRuleMatch { 32 | if eni != nil { 33 | for _, targetENISecurityGroupID := range eni.SecurityGroupIDs { 34 | if rule.TargetSecurityGroupReferenceID == targetENISecurityGroupID { // TODO: Handle SG Account ID 35 | return &securityGroupRuleMatch{ 36 | Basis: securityGroupRuleMatchBasisSGRef, 37 | Requirement: rule.TargetSecurityGroupReferenceID, 38 | Value: targetENISecurityGroupID, 39 | } 40 | } 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /reach/aws/security_group_rule_direction.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type securityGroupRuleDirection string 4 | 5 | const securityGroupRuleDirectionInbound securityGroupRuleDirection = "inbound" 6 | const securityGroupRuleDirectionOutbound securityGroupRuleDirection = "outbound" 7 | -------------------------------------------------------------------------------- /reach/aws/security_group_rule_explanation_view_model.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/luhring/reach/reach/helper" 7 | ) 8 | 9 | type securityGroupRuleExplanationViewModel struct { 10 | securityGroupName string 11 | allowedTraffic string 12 | inclusionReason string 13 | } 14 | 15 | func (model securityGroupRuleExplanationViewModel) String() string { 16 | output := "- rule\n" 17 | 18 | allowedTrafficHeader := "network traffic allowed:" 19 | allowedTrafficSection := fmt.Sprintf("%s\n%s", allowedTrafficHeader, helper.Indent(model.allowedTraffic, 2)) 20 | output += helper.Indent(allowedTrafficSection, 4) 21 | 22 | inclusionReasonHeader := "reason for inclusion:" 23 | inclusionReasonSection := fmt.Sprintf("%s\n%s\n", inclusionReasonHeader, helper.Indent(model.inclusionReason, 2)) 24 | output += helper.Indent(inclusionReasonSection, 4) 25 | 26 | return output 27 | } 28 | -------------------------------------------------------------------------------- /reach/aws/security_group_rule_match.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type securityGroupRuleMatch struct { 4 | Basis securityGroupRuleMatchBasis 5 | Requirement interface{} 6 | Value interface{} 7 | } 8 | -------------------------------------------------------------------------------- /reach/aws/security_group_rule_match_basis.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | type securityGroupRuleMatchBasis string 4 | 5 | const securityGroupRuleMatchBasisIP securityGroupRuleMatchBasis = "IP" 6 | const securityGroupRuleMatchBasisSGRef securityGroupRuleMatchBasis = "SecurityGroupReference" 7 | 8 | // String returns the string representation of a security group rule match. 9 | func (basis securityGroupRuleMatchBasis) String() string { 10 | switch basis { 11 | case securityGroupRuleMatchBasisIP: 12 | return "IP address" 13 | case securityGroupRuleMatchBasisSGRef: 14 | return "attached security group" 15 | default: 16 | return "[unknown match basis]" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /reach/aws/security_group_rules_factor.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/luhring/reach/reach" 5 | ) 6 | 7 | // FactorKindSecurityGroupRules specifies the unique name for the security group rules kind of factor. 8 | const FactorKindSecurityGroupRules = "SecurityGroupRules" 9 | 10 | type securityGroupRulesFactor struct { 11 | RuleComponents []securityGroupRulesFactorComponent 12 | } 13 | 14 | func (eni ElasticNetworkInterface) newSecurityGroupRulesFactor( 15 | rc *reach.ResourceCollection, 16 | p reach.Perspective, 17 | awsP perspective, 18 | targetENI *ElasticNetworkInterface, 19 | ) (*reach.Factor, error) { 20 | var ruleComponents []securityGroupRulesFactorComponent 21 | var trafficContentSegments []reach.TrafficContent 22 | 23 | for _, id := range eni.SecurityGroupIDs { 24 | ref := reach.ResourceReference{ 25 | Domain: ResourceDomainAWS, 26 | Kind: ResourceKindSecurityGroup, 27 | ID: id, 28 | } 29 | 30 | sg := rc.Get(ref).Properties.(SecurityGroup) 31 | 32 | for ruleIndex, rule := range awsP.securityGroupRules(sg) { 33 | var match *securityGroupRuleMatch 34 | 35 | // check ip match 36 | match = rule.matchByIP(p.Other.IPAddress) 37 | 38 | // check SG ref match (only if we don't already have a match) 39 | if match == nil { 40 | match = rule.matchBySecurityGroup(targetENI) 41 | } 42 | 43 | if match != nil { 44 | component := securityGroupRulesFactorComponent{ 45 | SecurityGroup: ref, 46 | RuleDirection: awsP.securityGroupRuleDirection, 47 | RuleIndex: ruleIndex, 48 | Match: *match, 49 | Traffic: rule.TrafficContent, 50 | } 51 | 52 | trafficContentSegments = append(trafficContentSegments, rule.TrafficContent) 53 | ruleComponents = append(ruleComponents, component) 54 | } 55 | } 56 | } 57 | 58 | tc, err := reach.NewTrafficContentFromMergingMultiple(trafficContentSegments) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | props := securityGroupRulesFactor{ 64 | RuleComponents: ruleComponents, 65 | } 66 | 67 | return &reach.Factor{ 68 | Kind: FactorKindSecurityGroupRules, 69 | Resource: eni.ToResourceReference(), 70 | Traffic: tc, 71 | ReturnTraffic: reach.NewTrafficContentForAllTraffic(), 72 | Properties: props, 73 | }, nil 74 | } 75 | -------------------------------------------------------------------------------- /reach/aws/security_group_rules_factor_component.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | type securityGroupRulesFactorComponent struct { 6 | SecurityGroup reach.ResourceReference 7 | RuleDirection securityGroupRuleDirection 8 | RuleIndex int 9 | Match securityGroupRuleMatch 10 | Traffic reach.TrafficContent 11 | } 12 | -------------------------------------------------------------------------------- /reach/aws/subnet.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // ResourceKindSubnet specifies the unique name for the subnet kind of resource. 6 | const ResourceKindSubnet = "Subnet" 7 | 8 | // A Subnet resource representation. 9 | type Subnet struct { 10 | ID string 11 | NetworkACLID string 12 | VPCID string 13 | } 14 | 15 | // ToResource returns the subnet converted to a generalized Reach resource. 16 | func (s Subnet) ToResource() reach.Resource { 17 | return reach.Resource{ 18 | Kind: ResourceKindSubnet, 19 | Properties: s, 20 | } 21 | } 22 | 23 | // Dependencies returns a collection of the subnet's resource dependencies. 24 | func (s Subnet) Dependencies(provider ResourceProvider) (*reach.ResourceCollection, error) { 25 | rc := reach.NewResourceCollection() 26 | 27 | networkACL, err := provider.NetworkACL(s.NetworkACLID) 28 | if err != nil { 29 | return nil, err 30 | } 31 | rc.Put(reach.ResourceReference{ 32 | Domain: ResourceDomainAWS, 33 | Kind: ResourceKindNetworkACL, 34 | ID: s.NetworkACLID, 35 | }, networkACL.ToResource()) 36 | 37 | vpc, err := provider.VPC(s.VPCID) 38 | if err != nil { 39 | return nil, err 40 | } 41 | rc.Put(reach.ResourceReference{ 42 | Domain: ResourceDomainAWS, 43 | Kind: ResourceKindVPC, 44 | ID: vpc.ID, 45 | }, vpc.ToResource()) 46 | 47 | return rc, nil 48 | } 49 | -------------------------------------------------------------------------------- /reach/aws/vector_analyzer.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | // VectorAnalyzer is the AWS-specific implementation of the VectorAnalyzer interface. 10 | type VectorAnalyzer struct { 11 | resourceCollection *reach.ResourceCollection 12 | } 13 | 14 | // NewVectorAnalyzer creates a new AWS-specific VectorAnalyzer. 15 | func NewVectorAnalyzer(resourceCollection *reach.ResourceCollection) VectorAnalyzer { 16 | return VectorAnalyzer{ 17 | resourceCollection, 18 | } 19 | } 20 | 21 | func (analyzer VectorAnalyzer) factorsForPerspective(p reach.Perspective) ([]reach.Factor, error) { 22 | var factors []reach.Factor 23 | 24 | for _, resourceRef := range p.Self.Lineage { 25 | if resourceRef.Domain == ResourceDomainAWS { 26 | if resourceRef.Kind == ResourceKindEC2Instance { 27 | ec2Instance := analyzer.resourceCollection.Get(resourceRef).Properties.(EC2Instance) 28 | 29 | factors = append(factors, ec2Instance.newInstanceStateFactor()) 30 | } 31 | 32 | if resourceRef.Kind == ResourceKindElasticNetworkInterface { 33 | // Get ready to evaluate factors 34 | eni := analyzer.resourceCollection.Get(resourceRef).Properties.(ElasticNetworkInterface) 35 | targetENI := ElasticNetworkInterfaceFromNetworkPoint(p.Other, analyzer.resourceCollection) 36 | 37 | var awsP perspective 38 | if p.SelfRole == reach.SubjectRoleSource { 39 | awsP = newPerspectiveSourceOriented() 40 | } else { 41 | awsP = newPerspectiveDestinationOriented() 42 | } 43 | 44 | // Ensure this is scenario that Reach can analyze 45 | if !sameVPC(&eni, targetENI) { 46 | return nil, fmt.Errorf("error: reach is not yet able to analyze EC2 instances in different VPCs, but that's coming soon! (VPCs: %s, %s)", eni.VPCID, targetENI.VPCID) 47 | } 48 | 49 | // Evaluate factors 50 | securityGroupRulesFactor, err := eni.newSecurityGroupRulesFactor( 51 | analyzer.resourceCollection, 52 | p, 53 | awsP, 54 | targetENI, 55 | ) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | factors = append(factors, *securityGroupRulesFactor) 61 | 62 | if sameSubnet(&eni, targetENI) { 63 | // There's nothing further to evaluate for this ENI 64 | continue 65 | } 66 | 67 | // Different subnets, same VPC 68 | 69 | networkACLRulesFactor, err := eni.newNetworkACLRulesFactor( 70 | analyzer.resourceCollection, 71 | p, 72 | awsP, 73 | targetENI, 74 | ) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | factors = append(factors, *networkACLRulesFactor) 80 | } 81 | } 82 | } 83 | 84 | return factors, nil 85 | } 86 | 87 | // Factors calculates the analysis factors for the given network vector. 88 | func (analyzer VectorAnalyzer) Factors(v reach.NetworkVector) ([]reach.Factor, reach.NetworkVector, error) { 89 | var factors []reach.Factor 90 | 91 | sourcePerspective := v.SourcePerspective() 92 | sourceFactors, err := analyzer.factorsForPerspective(sourcePerspective) 93 | if err != nil { 94 | return nil, reach.NetworkVector{}, err 95 | } 96 | 97 | destinationPerspective := v.DestinationPerspective() 98 | destinationFactors, err := analyzer.factorsForPerspective(destinationPerspective) 99 | if err != nil { 100 | return nil, reach.NetworkVector{}, err 101 | } 102 | 103 | factors = append(factors, sourceFactors...) 104 | factors = append(factors, destinationFactors...) 105 | 106 | v.Source.Factors = sourceFactors 107 | v.Destination.Factors = destinationFactors 108 | 109 | return factors, v, nil 110 | } 111 | 112 | func sameSubnet(first, second *ElasticNetworkInterface) bool { 113 | if first == nil || second == nil { 114 | return false 115 | } 116 | 117 | return first.SubnetID == second.SubnetID 118 | } 119 | 120 | func sameVPC(first, second *ElasticNetworkInterface) bool { 121 | if first == nil || second == nil { 122 | return false 123 | } 124 | 125 | return first.VPCID == second.VPCID 126 | } 127 | -------------------------------------------------------------------------------- /reach/aws/vector_discoverer.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/luhring/reach/reach" 4 | 5 | // VectorDiscoverer is the AWS-specific implementation of the VectorDiscoverer interface. 6 | type VectorDiscoverer struct { 7 | resourceCollection *reach.ResourceCollection 8 | } 9 | 10 | // NewVectorDiscoverer creates a new AWS-specific VectorDiscoverer. 11 | func NewVectorDiscoverer(resourceCollection *reach.ResourceCollection) VectorDiscoverer { 12 | return VectorDiscoverer{ 13 | resourceCollection, 14 | } 15 | } 16 | 17 | // Discover identifies all of the network vectors that could exist between the given subjects. 18 | func (d VectorDiscoverer) Discover(subjects []*reach.Subject) ([]reach.NetworkVector, error) { 19 | // TODO: Re-evaluate: As non-AWS network points are introduced, we may need to rethink how we divvy up this logic 20 | 21 | var sourceNetworkPoints []reach.NetworkPoint 22 | var destinationNetworkPoints []reach.NetworkPoint 23 | 24 | for _, subject := range subjects { 25 | if subject.Role == reach.SubjectRoleSource { 26 | switch subject.Domain { 27 | case ResourceDomainAWS: 28 | switch subject.Kind { 29 | case SubjectKindEC2Instance: 30 | ec2Instance := d.resourceCollection.Get(reach.ResourceReference{ 31 | Domain: ResourceDomainAWS, 32 | Kind: ResourceKindEC2Instance, 33 | ID: subject.ID, 34 | }).Properties.(EC2Instance) 35 | 36 | sourceNetworkPoints = append(sourceNetworkPoints, ec2Instance.networkPoints(d.resourceCollection)...) 37 | } 38 | } 39 | } else if subject.Role == reach.SubjectRoleDestination { 40 | switch subject.Domain { 41 | case ResourceDomainAWS: 42 | switch subject.Kind { 43 | case SubjectKindEC2Instance: 44 | ec2Instance := d.resourceCollection.Get(reach.ResourceReference{ 45 | Domain: ResourceDomainAWS, 46 | Kind: ResourceKindEC2Instance, 47 | ID: subject.ID, 48 | }).Properties.(EC2Instance) 49 | 50 | destinationNetworkPoints = append(destinationNetworkPoints, ec2Instance.networkPoints(d.resourceCollection)...) 51 | } 52 | } 53 | } 54 | } 55 | 56 | var networkVectors []reach.NetworkVector 57 | 58 | for _, source := range sourceNetworkPoints { 59 | for _, destination := range destinationNetworkPoints { 60 | vector, err := reach.NewNetworkVector(source, destination) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | networkVectors = append(networkVectors, vector) 66 | } 67 | } 68 | 69 | return networkVectors, nil 70 | } 71 | -------------------------------------------------------------------------------- /reach/aws/vpc.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/luhring/reach/reach" 7 | ) 8 | 9 | // ResourceKindVPC specifies the unique name for the VPC kind of resource. 10 | const ResourceKindVPC = "VPC" 11 | 12 | // An VPC resource representation. 13 | type VPC struct { 14 | ID string 15 | IPv4CIDRs []net.IPNet `json:"IPv4CIDRs,omitempty"` 16 | IPv6CIDRs []net.IPNet `json:"IPv6CIDRs,omitempty"` 17 | } 18 | 19 | // ToResource returns the VPC converted to a generalized Reach resource. 20 | func (vpc VPC) ToResource() reach.Resource { 21 | return reach.Resource{ 22 | Kind: ResourceKindVPC, 23 | Properties: vpc, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /reach/custom_protocols.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | var ipProtocols = map[Protocol]string{ 4 | 0: "HOPOPT", 5 | 1: "ICMP", 6 | 2: "IGMP", 7 | 3: "GGP", 8 | 4: "IPv4", 9 | 5: "ST", 10 | 6: "TCP", 11 | 7: "CBT", 12 | 8: "EGP", 13 | 9: "IGP", 14 | 10: "BBN-RCC-MON", 15 | 11: "NVP-II", 16 | 12: "PUP", 17 | 13: "ARGUS", 18 | 14: "EMCON", 19 | 15: "XNET", 20 | 16: "CHAOS", 21 | 17: "UDP", 22 | 18: "MUX", 23 | 19: "DCN-MEAS", 24 | 20: "HMP", 25 | 21: "PRM", 26 | 22: "XNS-IDP", 27 | 23: "TRUNK-1", 28 | 24: "TRUNK-2", 29 | 25: "LEAF-1", 30 | 26: "LEAF-2", 31 | 27: "RDP", 32 | 28: "IRTP", 33 | 29: "ISO-TP4", 34 | 30: "NETBLT", 35 | 31: "MFE-NSP", 36 | 32: "MERIT-INP", 37 | 33: "DCCP", 38 | 34: "3PC", 39 | 35: "IDPR", 40 | 36: "XTP", 41 | 37: "DDP", 42 | 38: "IDPR-CMTP", 43 | 39: "TP++", 44 | 40: "IL", 45 | 41: "IPv6", 46 | 42: "SDRP", 47 | 43: "IPv6-Route", 48 | 44: "IPv6-Frag", 49 | 45: "IDRP", 50 | 46: "RSVP", 51 | 47: "GRE", 52 | 48: "DSR", 53 | 49: "BNA", 54 | 50: "ESP", 55 | 51: "AH", 56 | 52: "I-NLSP", 57 | 53: "SWIPE", 58 | 54: "NARP", 59 | 55: "MOBILE", 60 | 56: "TLSP", 61 | 57: "SKIP", 62 | 58: "IPv6-ICMP", 63 | 59: "IPv6-NoNxt", 64 | 60: "IPv6-Opts", 65 | 62: "CFTP", 66 | 64: "SAT-EXPAK", 67 | 65: "KRYPTOLAN", 68 | 66: "RVD", 69 | 67: "IPPC", 70 | 69: "SAT-MON", 71 | 70: "VISA", 72 | 71: "IPCV", 73 | 72: "CPNX", 74 | 73: "CPHB", 75 | 74: "WSN", 76 | 75: "PVP", 77 | 76: "BR-SAT-MON", 78 | 77: "SUN-ND", 79 | 78: "WB-MON", 80 | 79: "WB-EXPAK", 81 | 80: "ISO-IP", 82 | 81: "VMTP", 83 | 82: "SECURE-VMTP", 84 | 83: "VINES", 85 | 84: "TTP/IPTM", 86 | 85: "NSFNET-IGP", 87 | 86: "DGP", 88 | 87: "TCF", 89 | 88: "EIGRP", 90 | 89: "OSPFIGP", 91 | 90: "Sprite-RPC", 92 | 91: "LARP", 93 | 92: "MTP", 94 | 93: "AX.25", 95 | 94: "IPIP", 96 | 95: "MICP", 97 | 96: "SCC-SP", 98 | 97: "ETHERIP", 99 | 98: "ENCAP", 100 | 100: "GMTP", 101 | 101: "IFMP", 102 | 102: "PNNI", 103 | 103: "PIM", 104 | 104: "ARIS", 105 | 105: "SCPS", 106 | 106: "QNX", 107 | 107: "A/N", 108 | 108: "IPComp", 109 | 109: "SNP", 110 | 110: "Compaq-Peer", 111 | 111: "IPX-in-IP", 112 | 112: "VRRP", 113 | 113: "PGM", 114 | 115: "L2TP", 115 | 116: "DDX", 116 | 117: "IATP", 117 | 118: "STP", 118 | 119: "SRP", 119 | 120: "UTI", 120 | 121: "SMP", 121 | 122: "SM", 122 | 123: "PTP", 123 | 124: "ISIS over IPv4", 124 | 125: "FIRE", 125 | 126: "CRTP", 126 | 127: "CRUDP", 127 | 128: "SSCOPMCE", 128 | 129: "IPLT", 129 | 130: "SPS", 130 | 131: "PIPE", 131 | 132: "SCTP", 132 | 133: "FC", 133 | 134: "RSVP-E2E-IGNORE", 134 | 135: "Mobility Header", 135 | 136: "UDPLite", 136 | 137: "MPLS-in-IP", 137 | 138: "manet", 138 | 139: "HIP", 139 | 140: "Shim6", 140 | 141: "WESP", 141 | 142: "ROHC", 142 | } 143 | -------------------------------------------------------------------------------- /reach/explainer/explainer.go: -------------------------------------------------------------------------------- 1 | package explainer 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/mgutz/ansi" 9 | 10 | "github.com/luhring/reach/reach" 11 | "github.com/luhring/reach/reach/aws" 12 | "github.com/luhring/reach/reach/helper" 13 | ) 14 | 15 | // An Explainer provides mechanisms to explain the business logic behind analyses to users via natural language. 16 | type Explainer struct { 17 | analysis reach.Analysis 18 | } 19 | 20 | // New returns a reference to a new Explainer. 21 | func New(analysis reach.Analysis) *Explainer { 22 | return &Explainer{ 23 | analysis: analysis, 24 | } 25 | } 26 | 27 | // Explain returns a natural language representation of the logic used during an analysis to compute the final result. 28 | func (ex *Explainer) Explain() string { 29 | var outputItems []string 30 | for _, v := range ex.analysis.NetworkVectors { 31 | outputItems = append(outputItems, ex.ExplainNetworkVector(v)) 32 | } 33 | 34 | output := "" 35 | output += strings.Join(outputItems, "\n") 36 | 37 | return output 38 | } 39 | 40 | // ExplainNetworkVector returns the part of an analysis explanation that's specific to an individual network vector. 41 | func (ex *Explainer) ExplainNetworkVector(v reach.NetworkVector) string { 42 | var outputSections []string 43 | 44 | // setting the stage: the source and destination 45 | var vectorHeader string 46 | vectorHeader += fmt.Sprintf("%s %s\n", helper.Bold("source:"), ex.NetworkPointName(v.Source)) 47 | vectorHeader += fmt.Sprintf("%s %s\n", helper.Bold("destination:"), ex.NetworkPointName(v.Destination)) 48 | outputSections = append(outputSections, vectorHeader) 49 | 50 | // explain source 51 | sourceHeader := helper.Bold("source factors:") 52 | outputSections = append(outputSections, sourceHeader) 53 | 54 | sourceContent := ex.ExplainNetworkPoint(v.Source, v.SourcePerspective()) 55 | outputSections = append(outputSections, helper.Indent(sourceContent, 2)) 56 | 57 | // explain destination 58 | destinationHeader := helper.Bold("destination factors:") 59 | outputSections = append(outputSections, destinationHeader) 60 | 61 | destinationContent := ex.ExplainNetworkPoint(v.Destination, v.DestinationPerspective()) 62 | outputSections = append(outputSections, helper.Indent(destinationContent, 2)) 63 | 64 | // final results 65 | forwardResults := fmt.Sprintf("%s\n%s", helper.Bold("network traffic allowed from source to destination:"), v.Traffic.ColorStringWithSymbols()) 66 | outputSections = append(outputSections, forwardResults) 67 | 68 | returnResults := fmt.Sprintf("%s\n%s", helper.Bold("network traffic allowed to return from destination to source:"), v.ReturnTraffic.StringWithSymbols()) 69 | outputSections = append(outputSections, returnResults) 70 | 71 | return strings.Join(outputSections, "\n") 72 | } 73 | 74 | // ExplainCapabilityChecks returns a report on whether or not Reach's capabilities are sufficient to handle the requested analysis. 75 | func (ex *Explainer) ExplainCapabilityChecks(v reach.NetworkVector) string { 76 | var outputItems []string 77 | var checksItems []string 78 | 79 | checksHeader := helper.Bold("analysis capability checks:") 80 | outputItems = append(outputItems, checksHeader) 81 | 82 | awsEx := aws.NewExplainer(ex.analysis) 83 | 84 | if awsEx.CheckBothInAWS(v) { 85 | checksItems = append(checksItems, "✓ both source and destination are in AWS") 86 | } else { 87 | log.Fatal("source and/or destination is not in AWS, and this is not yet supported") 88 | } 89 | 90 | if awsEx.CheckBothInSameVPC(v) { 91 | checksItems = append(checksItems, "✓ both source and destination are in same VPC") 92 | } else { 93 | log.Fatal("source and/or destination are not in same VPC, and this is not yet supported") 94 | } 95 | 96 | if awsEx.CheckBothInSameSubnet(v) { 97 | checksItems = append(checksItems, "✓ both source and destination are in same subnet") 98 | } else { 99 | log.Fatal("source and/or destination are not in same subnet, and this is not yet supported") 100 | } 101 | 102 | outputItems = append(outputItems, checksItems...) 103 | 104 | return strings.Join(outputItems, "\n") 105 | } 106 | 107 | // ExplainNetworkPoint returns the part of an analysis explanation that's specific to an individual network point (within a network vector). 108 | func (ex *Explainer) ExplainNetworkPoint(point reach.NetworkPoint, p reach.Perspective) string { 109 | if aws.IsUsedByNetworkPoint(point) { 110 | awsEx := aws.NewExplainer(ex.analysis) 111 | return awsEx.NetworkPoint(point, p) 112 | } 113 | 114 | return fmt.Sprintf("unable to explain analysis for network point with IP address '%s'", point.IPAddress) 115 | } 116 | 117 | // NetworkPointName returns an understandable string representation of a network point. 118 | func (ex *Explainer) NetworkPointName(point reach.NetworkPoint) string { 119 | // ignoring errors because it's okay if we can't find a particular kind of AWS resource in the lineage 120 | eni, _ := aws.GetENIFromLineage(point.Lineage, ex.analysis.Resources) 121 | ec2Instance, _ := aws.GetEC2InstanceFromLineage(point.Lineage, ex.analysis.Resources) 122 | 123 | output := point.IPAddress.String() 124 | 125 | if eni != nil { 126 | output = fmt.Sprintf("%s -> %s", eni.Name(), output) 127 | 128 | if ec2Instance != nil { 129 | output = fmt.Sprintf("%s -> %s", ec2Instance.Name(), output) 130 | } 131 | } 132 | 133 | return output 134 | } 135 | 136 | // WarningsFromRestrictedReturnPath returns a slice of warning strings based on the input slice of restricted protocols. 137 | func WarningsFromRestrictedReturnPath(restrictedProtocols []reach.RestrictedProtocol) (bool, string) { 138 | if len(restrictedProtocols) == 0 { 139 | return false, "" 140 | } 141 | 142 | var warnings []string 143 | 144 | for _, rp := range restrictedProtocols { 145 | var warning string 146 | 147 | if rp.Protocol == reach.ProtocolTCP { // We have a specific message based on the knowledge that the protocol is TCP. 148 | if rp.NoReturnTraffic { 149 | warning = ansi.Color("All TCP connection attempts will be unsuccessful. No TCP traffic is allowed to return to the source.", "red+b") 150 | } else { 151 | warning = ansi.Color("TCP connection attempts might be unsuccessful. TCP traffic is allowed to return to the source only at particular source ports.", "yellow+b") 152 | } 153 | } else { 154 | firstSentence := fmt.Sprintf("%s-based communication might be unsuccessful.", rp.Protocol) 155 | 156 | var secondSentence string 157 | 158 | if rp.NoReturnTraffic { 159 | secondSentence = fmt.Sprintf("No %s traffic is able to return to the source.", rp.Protocol) 160 | } else { 161 | secondSentence = fmt.Sprintf("Some %s traffic is unable to return to the source.", rp.Protocol) 162 | } 163 | 164 | warning = ansi.Color(fmt.Sprintf("%s %s", firstSentence, secondSentence), "yellow+b") 165 | } 166 | 167 | warnings = append(warnings, warning) 168 | } 169 | 170 | return true, "warnings from return traffic obstructions:\n" + strings.Join(warnings, "\n") 171 | } 172 | -------------------------------------------------------------------------------- /reach/factor.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | // A Factor describes how a particular component of the ingested resources has an impact on the network traffic allowed to flow from a source to a destination. 4 | type Factor struct { 5 | Kind string 6 | Resource ResourceReference 7 | Traffic TrafficContent 8 | ReturnTraffic TrafficContent 9 | Properties interface{} `json:"Properties,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /reach/helper/strings.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/mgutz/ansi" 7 | ) 8 | 9 | // PrefixLines adds a given prefix to every line in a given slice of lines and returns the result as a single string value. 10 | func PrefixLines(lines []string, prefix string) string { 11 | var outputLines []string 12 | 13 | for _, line := range lines { 14 | if line == "" { 15 | outputLines = append(outputLines, line) 16 | } else { 17 | outputLines = append(outputLines, prefix+line) 18 | } 19 | } 20 | 21 | return strings.Join(outputLines, "\n") 22 | } 23 | 24 | // Indent adds one or more spaces at the beginning of each line of the given text and returns the result. 25 | func Indent(text string, spaces int) string { 26 | if spaces < 1 { 27 | return text 28 | } 29 | 30 | lines := strings.Split(text, "\n") 31 | var outputLines []string 32 | 33 | var indentation string 34 | for i := 0; i < spaces; i++ { 35 | indentation += " " 36 | } 37 | 38 | for _, line := range lines { 39 | // don't indent empty lines 40 | if line == "" { 41 | outputLines = append(outputLines, line) 42 | } else { 43 | outputLine := indentation + line 44 | outputLines = append(outputLines, outputLine) 45 | } 46 | } 47 | 48 | return strings.Join(outputLines, "\n") 49 | } 50 | 51 | // Bold uses ANSI escape characters to return the bolded version of the input text. 52 | func Bold(text string) string { 53 | return ansi.Color(text, "default+b") 54 | } 55 | -------------------------------------------------------------------------------- /reach/network_point.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | ) 7 | 8 | // A NetworkPoint is a point of termination for an analyzed network vector (on either the source or destination side), such that there is no further subdivision of a source or destination possible beyond the network point. For example, the CIDR block "10.0.1.0/24" contains numerous individual IP addresses, and the analysis result might vary depending on which of these individual IP addresses is used in real network traffic. To break this problem down, such that an analysis result is as definitive as possible, each individual IP address must be analyzed, one at a time. Each IP address could be considered a network point, whereas the CIDR block could not be considered a network point. 9 | type NetworkPoint struct { 10 | IPAddress net.IP 11 | Lineage []ResourceReference 12 | Factors []Factor 13 | } 14 | 15 | func (point NetworkPoint) trafficContents() []TrafficContent { 16 | var components []TrafficContent 17 | 18 | for _, factor := range point.Factors { 19 | components = append(components, factor.Traffic) 20 | } 21 | 22 | return components 23 | } 24 | 25 | // String returns the text representation of the NetworkPoint 26 | func (point NetworkPoint) String() string { 27 | var generations []string 28 | 29 | for i := len(point.Lineage) - 1; i >= 0; i-- { 30 | generations = append(generations, point.Lineage[i].ID) 31 | } 32 | 33 | generations = append(generations, point.IPAddress.String()) 34 | 35 | return strings.Join(generations, " -> ") 36 | } 37 | -------------------------------------------------------------------------------- /reach/network_vector.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nu7hatch/gouuid" 7 | ) 8 | 9 | // A NetworkVector represents the path between two network points that's able to be analyzed in terms of what kind of network traffic is allowed to flow from point to point. 10 | type NetworkVector struct { 11 | ID string 12 | Source NetworkPoint 13 | Destination NetworkPoint 14 | Traffic *TrafficContent 15 | ReturnTraffic *TrafficContent 16 | } 17 | 18 | // NewNetworkVector creates a new network vector given a source and a destination network point. 19 | func NewNetworkVector(source, destination NetworkPoint) (NetworkVector, error) { 20 | u, err := uuid.NewV4() 21 | if err != nil { 22 | return NetworkVector{}, err 23 | } 24 | 25 | return NetworkVector{ 26 | ID: u.String(), 27 | Source: source, 28 | Destination: destination, 29 | }, nil 30 | } 31 | 32 | // String returns the text representation of a NetworkVector. 33 | func (v NetworkVector) String() string { 34 | output := "" 35 | output += fmt.Sprintf("* network vector ID: %s\n", v.ID) 36 | output += fmt.Sprintf("* source network point: %s\n* destination network point: %s\n", v.Source.String(), v.Destination.String()) 37 | 38 | if v.Traffic != nil { 39 | output += "\n" 40 | output += v.Traffic.String() 41 | output += "\n" 42 | output += "network traffic allowed to return from destination to source:\n" 43 | output += "\n" 44 | output += v.ReturnTraffic.String() 45 | } 46 | 47 | return output 48 | } 49 | 50 | // SourcePerspective returns an analyzable Perspective based on the NetworkVector's source network point. 51 | func (v NetworkVector) SourcePerspective() Perspective { 52 | return Perspective{ 53 | Self: v.Source, 54 | Other: v.Destination, 55 | SelfRole: SubjectRoleSource, 56 | OtherRole: SubjectRoleDestination, 57 | } 58 | } 59 | 60 | // DestinationPerspective returns an analyzable Perspective based on the NetworkVector's destination network point. 61 | func (v NetworkVector) DestinationPerspective() Perspective { 62 | return Perspective{ 63 | Self: v.Destination, 64 | Other: v.Source, 65 | SelfRole: SubjectRoleDestination, 66 | OtherRole: SubjectRoleSource, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /reach/perspective.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | // A Perspective provides a reference to one direction of a network vector with knowledge of which network point is currently being analyzed ("self") and which network point is the "other" or "target" network point, such that properties of the "other" network point can be used when determining of it applies to the analysis of the "self" network point. 4 | type Perspective struct { 5 | Self NetworkPoint 6 | Other NetworkPoint 7 | SelfRole SubjectRole 8 | OtherRole SubjectRole 9 | } 10 | -------------------------------------------------------------------------------- /reach/protocol.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // A Protocol represents an analyzable IP protocol, whose integer value corresponds to the officially assigned number for the IP protocol (as defined here: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml). 8 | type Protocol int 9 | 10 | // Protocol numbers for the most common IP protocols. 11 | const ( 12 | ProtocolAll Protocol = -1 13 | ProtocolICMPv4 Protocol = 1 14 | ProtocolTCP Protocol = 6 15 | ProtocolUDP Protocol = 17 16 | ProtocolICMPv6 Protocol = 58 17 | ) 18 | 19 | // Names of the most common IP protocols. 20 | const ( 21 | ProtocolNameAll = "all" 22 | ProtocolNameICMPv4 = "ICMPv4" 23 | ProtocolNameTCP = "TCP" 24 | ProtocolNameUDP = "UDP" 25 | ProtocolNameICMPv6 = "ICMPv6" 26 | ) 27 | 28 | // UsesPorts returns a boolean indicating whether or not the described protocol has a "ports" concept that can be further drilled into when analyzing network rules. UsesPorts returns true if the underlying protocol is either TCP or UDP. 29 | func (p Protocol) UsesPorts() bool { 30 | return p == ProtocolTCP || p == ProtocolUDP 31 | } 32 | 33 | // UsesICMPTypeCodes returns a boolean indicating whether or not the underlying protocol is ICMP (v4) or ICMPv6. 34 | func (p Protocol) UsesICMPTypeCodes() bool { 35 | return p == ProtocolICMPv4 || p == ProtocolICMPv6 36 | } 37 | 38 | // IsCustomProtocol returns a boolean indicating whether or not the underlying protocol is "custom", meaning that it's not TCP, UDP, ICMPv4, or ICMPv6. The significance of this distinction is that Reach can analyze custom protocols only on an "all-or-nothing" basis, in contrast to protocols like TCP, where Reach can further assess traffic flow on a more granular basis, like ports. 39 | func (p Protocol) IsCustomProtocol() bool { 40 | return p != ProtocolICMPv4 && p != ProtocolTCP && p != ProtocolUDP && p != ProtocolICMPv6 41 | } 42 | 43 | // String returns the common name of the IP protocol. 44 | func (p Protocol) String() string { 45 | return ProtocolName(p) 46 | } 47 | 48 | // ProtocolName returns the name of an IP protocol given the protocol's assigned number. 49 | func ProtocolName(protocol Protocol) string { 50 | switch protocol { 51 | case ProtocolICMPv4: 52 | return ProtocolNameICMPv4 53 | case ProtocolTCP: 54 | return ProtocolNameTCP 55 | case ProtocolUDP: 56 | return ProtocolNameUDP 57 | case ProtocolICMPv6: 58 | return ProtocolNameICMPv6 59 | default: 60 | return customProtocolName(protocol) 61 | } 62 | } 63 | 64 | func customProtocolName(protocol Protocol) string { 65 | name, exists := ipProtocols[protocol] 66 | if exists { 67 | return name 68 | } 69 | 70 | return fmt.Sprintf("IP protocol %d", protocol) 71 | } 72 | -------------------------------------------------------------------------------- /reach/protocol_content.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/luhring/reach/reach/set" 9 | ) 10 | 11 | // ProtocolContent specifies a set of network traffic for a single, specified IP protocol. 12 | type ProtocolContent struct { 13 | Protocol Protocol 14 | Ports *set.PortSet `json:"Ports,omitempty"` 15 | ICMP *set.ICMPSet `json:"ICMP,omitempty"` 16 | CustomProtocolHasContent *bool `json:"CustomProtocolHasContent,omitempty"` 17 | } 18 | 19 | func newProtocolContent(protocol Protocol, ports *set.PortSet, icmp *set.ICMPSet, customProtocolHasContent *bool) ProtocolContent { 20 | if protocol < 0 { 21 | log.Panicf("unexpected protocol value: %v", protocol) // TODO: Handle error better 22 | } 23 | 24 | return ProtocolContent{ 25 | protocol, 26 | ports, 27 | icmp, 28 | customProtocolHasContent, 29 | } 30 | } 31 | 32 | func newProtocolContentWithPorts(protocol Protocol, ports *set.PortSet) ProtocolContent { 33 | return newProtocolContent(protocol, ports, nil, nil) 34 | } 35 | 36 | func newProtocolContentWithPortsEmpty(protocol Protocol) ProtocolContent { 37 | ports := set.NewEmptyPortSet() 38 | return newProtocolContentWithPorts(protocol, &ports) 39 | } 40 | 41 | func newProtocolContentWithPortsFull(protocol Protocol) ProtocolContent { 42 | ports := set.NewFullPortSet() 43 | return newProtocolContentWithPorts(protocol, &ports) 44 | } 45 | 46 | func newProtocolContentWithICMP(protocol Protocol, icmp *set.ICMPSet) ProtocolContent { 47 | return newProtocolContent(protocol, nil, icmp, nil) 48 | } 49 | 50 | func newProtocolContentWithICMPEmpty(protocol Protocol) ProtocolContent { 51 | icmp := set.NewEmptyICMPSet() 52 | return newProtocolContentWithICMP(protocol, &icmp) 53 | } 54 | 55 | func newProtocolContentWithICMPFull(protocol Protocol) ProtocolContent { 56 | icmp := set.NewFullICMPSet() 57 | return newProtocolContentWithICMP(protocol, &icmp) 58 | } 59 | 60 | func newProtocolContentForCustomProtocol(protocol Protocol, hasContent bool) ProtocolContent { 61 | return newProtocolContent(protocol, nil, nil, &hasContent) 62 | } 63 | 64 | func newProtocolContentForCustomProtocolEmpty(protocol Protocol) ProtocolContent { 65 | hasContent := false 66 | return newProtocolContent(protocol, nil, nil, &hasContent) 67 | } 68 | 69 | func newProtocolContentForCustomProtocolFull(protocol Protocol) ProtocolContent { 70 | hasContent := true 71 | return newProtocolContent(protocol, nil, nil, &hasContent) 72 | } 73 | 74 | func (pc ProtocolContent) empty() bool { 75 | if pc.isTCPOrUDP() { 76 | return pc.Ports.Empty() 77 | } else if pc.isICMPv4OrICMPv6() { 78 | return pc.ICMP.Empty() 79 | } else { 80 | return !*pc.CustomProtocolHasContent 81 | } 82 | } 83 | 84 | func (pc ProtocolContent) complete() bool { 85 | if pc.isTCPOrUDP() { 86 | return pc.Ports.Complete() 87 | } else if pc.isICMPv4OrICMPv6() { 88 | return pc.ICMP.Complete() 89 | } else { 90 | return *pc.CustomProtocolHasContent 91 | } 92 | } 93 | 94 | // String returns the string representation of the protocol content. 95 | func (pc ProtocolContent) String() string { 96 | protocolName := ProtocolName(pc.Protocol) 97 | 98 | if !pc.empty() { 99 | if pc.isTCPOrUDP() { 100 | return fmt.Sprintf("%s %s", protocolName, pc.Ports.String()) 101 | } else if pc.isICMPv4OrICMPv6() { 102 | if pc.Protocol == ProtocolICMPv6 { 103 | return fmt.Sprintf("%s", pc.ICMP.StringV6()) 104 | } 105 | return fmt.Sprintf("%s", pc.ICMP.StringV4()) 106 | } 107 | return fmt.Sprintf("%s (all traffic)", protocolName) 108 | } 109 | return fmt.Sprintf("%s (no traffic)", protocolName) 110 | } 111 | 112 | func (pc ProtocolContent) lines() []string { 113 | protocolName := ProtocolName(pc.Protocol) 114 | 115 | if !pc.empty() { 116 | if pc.isTCPOrUDP() { 117 | 118 | var lines []string 119 | 120 | for _, rangeString := range pc.Ports.RangeStrings() { 121 | lines = append(lines, fmt.Sprintf("%s %s", protocolName, rangeString)) 122 | } 123 | return lines 124 | } else if pc.isICMPv4OrICMPv6() { 125 | if pc.Protocol == ProtocolICMPv6 { 126 | return pc.ICMP.RangeStringsV6() 127 | } 128 | return pc.ICMP.RangeStringsV4() 129 | } else { 130 | return []string{fmt.Sprintf("%s (all traffic)", protocolName)} 131 | } 132 | } 133 | 134 | return []string{fmt.Sprintf("%s (no traffic)", protocolName)} 135 | } 136 | 137 | func (pc ProtocolContent) isTCPOrUDP() bool { 138 | return pc.Protocol == ProtocolTCP || pc.Protocol == ProtocolUDP 139 | } 140 | 141 | func (pc ProtocolContent) isICMPv4OrICMPv6() bool { 142 | return pc.Protocol == ProtocolICMPv4 || pc.Protocol == ProtocolICMPv6 143 | } 144 | 145 | func (pc ProtocolContent) intersect(other ProtocolContent) (ProtocolContent, error) { 146 | if !pc.sameProtocolAs(other) { 147 | return ProtocolContent{}, fmt.Errorf( 148 | "cannot intersect with different protocols (IP protocols %v and %v)", 149 | pc.Protocol, 150 | other.Protocol, 151 | ) 152 | } 153 | 154 | // same protocols 155 | 156 | if pc.isTCPOrUDP() { 157 | portSet := pc.Ports.Intersect(*other.Ports) 158 | return newProtocolContentWithPorts(pc.Protocol, &portSet), nil 159 | } 160 | 161 | if pc.isICMPv4OrICMPv6() { 162 | icmpSet := pc.ICMP.Intersect(*other.ICMP) 163 | return newProtocolContentWithICMP(pc.Protocol, &icmpSet), nil 164 | } 165 | 166 | // custom Protocol 167 | 168 | if *pc.CustomProtocolHasContent && *other.CustomProtocolHasContent { 169 | return newProtocolContentForCustomProtocolFull(pc.Protocol), nil 170 | } 171 | 172 | return newProtocolContentForCustomProtocolEmpty(pc.Protocol), nil 173 | } 174 | 175 | func (pc ProtocolContent) merge(other ProtocolContent) (ProtocolContent, error) { 176 | if pc.sameProtocolAs(other) == false { 177 | return ProtocolContent{}, fmt.Errorf( 178 | "cannot merge with different protocols (IP protocols %v and %v)", 179 | pc.Protocol, 180 | other.Protocol, 181 | ) 182 | } 183 | 184 | // same protocols 185 | 186 | if pc.isTCPOrUDP() { 187 | portSet := pc.Ports.Merge(*other.Ports) 188 | return newProtocolContentWithPorts(pc.Protocol, &portSet), nil 189 | } 190 | 191 | if pc.isICMPv4OrICMPv6() { 192 | icmpSet := pc.ICMP.Merge(*other.ICMP) 193 | return newProtocolContentWithICMP(pc.Protocol, &icmpSet), nil 194 | } 195 | 196 | // custom Protocol 197 | 198 | if *pc.CustomProtocolHasContent || *other.CustomProtocolHasContent { 199 | return newProtocolContentForCustomProtocol(pc.Protocol, true), nil 200 | } 201 | 202 | return newProtocolContentForCustomProtocol(pc.Protocol, false), nil 203 | } 204 | 205 | func (pc ProtocolContent) subtract(other ProtocolContent) (ProtocolContent, error) { 206 | if pc.sameProtocolAs(other) == false { 207 | return ProtocolContent{}, fmt.Errorf( 208 | "cannot subtract with different protocols (IP protocols %v and %v)", 209 | pc.Protocol, 210 | other.Protocol, 211 | ) 212 | } 213 | 214 | // same protocols 215 | 216 | if pc.isTCPOrUDP() { 217 | portSet := pc.Ports.Subtract(*other.Ports) 218 | return newProtocolContentWithPorts(pc.Protocol, &portSet), nil 219 | } 220 | 221 | if pc.isICMPv4OrICMPv6() { 222 | icmpSet := pc.ICMP.Subtract(*other.ICMP) 223 | return newProtocolContentWithICMP(pc.Protocol, &icmpSet), nil 224 | } 225 | 226 | // custom Protocol 227 | 228 | if *other.CustomProtocolHasContent || false == *pc.CustomProtocolHasContent { 229 | return newProtocolContentForCustomProtocol(pc.Protocol, false), nil 230 | } 231 | 232 | return newProtocolContentForCustomProtocol(pc.Protocol, true), nil 233 | } 234 | 235 | func (pc ProtocolContent) sameProtocolAs(other ProtocolContent) bool { 236 | return pc.Protocol == other.Protocol 237 | } 238 | 239 | func (pc ProtocolContent) getProtocolName() string { 240 | switch pc.Protocol { 241 | case ProtocolAll: 242 | return ProtocolNameAll 243 | case ProtocolICMPv4: 244 | return ProtocolNameICMPv4 245 | case ProtocolTCP: 246 | return ProtocolNameTCP 247 | case ProtocolUDP: 248 | return ProtocolNameUDP 249 | case ProtocolICMPv6: 250 | return ProtocolNameICMPv6 251 | default: 252 | return string(pc.Protocol) 253 | } 254 | } 255 | 256 | func (pc ProtocolContent) usesNamedProtocol() bool { 257 | name := pc.getProtocolName() 258 | return strings.EqualFold(name, ProtocolNameTCP) || 259 | strings.EqualFold(name, ProtocolNameUDP) || 260 | strings.EqualFold(name, ProtocolNameICMPv4) || 261 | strings.EqualFold(name, ProtocolNameICMPv6) 262 | } 263 | -------------------------------------------------------------------------------- /reach/resource.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | // A Resource is a generic representation of any kind of resource from an infrastructure provider (e.g. AWS). The kind-specific properties can be provided via a kind-specific struct used for the Properties field. Then, given the Kind value, a consumer can assert the kind-specific type when reading the Properties field. Examples of a Resource include an EC2 instance, an AWS VPC, etc. 4 | type Resource struct { 5 | Kind string 6 | Properties interface{} 7 | } 8 | -------------------------------------------------------------------------------- /reach/resource_collection.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import "encoding/json" 4 | 5 | // A ResourceCollection is a structure used to store any number of Resources, across potentially multiple "domains" (e.g. AWS, GCP, Azure) and kinds (e.g. EC2 instance, subnet, etc.). 6 | type ResourceCollection struct { 7 | collection map[string]map[string]map[string]Resource 8 | } 9 | 10 | // NewResourceCollection returns a reference to a new, empty ResourceCollection. 11 | func NewResourceCollection() *ResourceCollection { 12 | collection := make(map[string]map[string]map[string]Resource) 13 | 14 | return &ResourceCollection{ 15 | collection: collection, 16 | } 17 | } 18 | 19 | // Put adds a new Resource to the ResourceCollection. 20 | func (rc *ResourceCollection) Put(ref ResourceReference, resource Resource) { 21 | rc.ensureResourcePathExists(ref.Domain, ref.Kind) 22 | 23 | other := NewResourceCollection() 24 | other.ensureResourcePathExists(ref.Domain, ref.Kind) 25 | other.collection[ref.Domain][ref.Kind][ref.ID] = resource 26 | 27 | rc.Merge(other) 28 | } 29 | 30 | // Get retrieves a Resource from the ResourceCollection. 31 | func (rc *ResourceCollection) Get(ref ResourceReference) *Resource { 32 | if _, exists := rc.collection[ref.Domain]; !exists { 33 | return nil 34 | } 35 | 36 | if _, exists := rc.collection[ref.Domain][ref.Kind]; !exists { 37 | return nil 38 | } 39 | 40 | if resource, exists := rc.collection[ref.Domain][ref.Kind][ref.ID]; exists { 41 | return &resource 42 | } 43 | return nil 44 | } 45 | 46 | // Merge safely merges two ResourceCollections such that any unique resource from either collection is represented in the merged collection. For any case where both collections contain a resource for a given domain, kind, and resource ID, the "other" (input parameter) resource will overwrite the corresponding resource in the first collection. 47 | func (rc *ResourceCollection) Merge(other *ResourceCollection) { 48 | for resourceDomain, resourceKinds := range rc.collection { // e.g. for AWS 49 | if _, exists := other.collection[resourceDomain]; !exists { // only A has AWS 50 | rc.collection[resourceDomain] = resourceKinds 51 | } else { // both have AWS 52 | rc.ensureResourcePathExists(resourceDomain, "") 53 | 54 | for resourceKind, resources := range rc.collection[resourceDomain] { // e.g. for EC2 instances 55 | if _, exists := other.collection[resourceDomain][resourceKind]; !exists { // only A has any EC2 instances 56 | rc.collection[resourceDomain][resourceKind] = resources 57 | } else { // both have some EC2 instances 58 | rc.ensureResourcePathExists(resourceDomain, resourceKind) 59 | 60 | for id, resource := range rc.collection[resourceDomain][resourceKind] { // e.g. for EC2 instance with ID i-abc123def456 61 | rc.collection[resourceDomain][resourceKind][id] = resource 62 | } 63 | 64 | for id, resource := range other.collection[resourceDomain][resourceKind] { 65 | rc.collection[resourceDomain][resourceKind][id] = resource 66 | } 67 | } 68 | } 69 | 70 | for resourceKind, resources := range other.collection[resourceDomain] { // e.g. for security groups 71 | if _, exists := rc.collection[resourceDomain][resourceKind]; !exists { // only B has any security groups 72 | rc.collection[resourceDomain][resourceKind] = resources 73 | } 74 | } 75 | } 76 | } 77 | 78 | for resourceDomain, resourceKinds := range other.collection { // e.g. for GCP 79 | if _, exists := rc.collection[resourceDomain]; !exists { // only B has GCP 80 | rc.collection[resourceDomain] = resourceKinds 81 | } 82 | } 83 | } 84 | 85 | // MarshalJSON returns the JSON representation of the ResourceCollection. 86 | func (rc *ResourceCollection) MarshalJSON() ([]byte, error) { 87 | return json.Marshal(rc.collection) 88 | } 89 | 90 | func (rc *ResourceCollection) ensureResourcePathExists(domain, kind string) { 91 | if domain == "" { 92 | return 93 | } 94 | 95 | if _, exists := rc.collection[domain]; !exists { 96 | rc.collection[domain] = newResourceDomainMap() 97 | } 98 | 99 | if kind == "" { 100 | return 101 | } 102 | 103 | if _, exists := rc.collection[domain][kind]; !exists { 104 | rc.collection[domain][kind] = newResourceKindMap() 105 | } 106 | 107 | return 108 | } 109 | 110 | func newResourceDomainMap() map[string]map[string]Resource { 111 | return make(map[string]map[string]Resource) 112 | } 113 | 114 | func newResourceKindMap() map[string]Resource { 115 | return make(map[string]Resource) 116 | } 117 | -------------------------------------------------------------------------------- /reach/resource_reference.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import "fmt" 4 | 5 | // ResourceReference uniquely identifies a Resource used by Reach. It specifies the resource's Domain (e.g. AWS), Kind (e.g. EC2 instance), and ID (e.g. "i-0136d3233f0ef1924"). 6 | type ResourceReference struct { 7 | Domain string 8 | Kind string 9 | ID string 10 | } 11 | 12 | // String returns the string representation of the ResourceReference. 13 | func (r ResourceReference) String() string { 14 | return fmt.Sprintf("%s->%s->%s", r.Domain, r.Kind, r.ID) 15 | } 16 | -------------------------------------------------------------------------------- /reach/set/icmp_set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Constants to handle ingestion of data that refers to all ICMP types or codes. 9 | const ( 10 | AllICMPTypes = -1 11 | AllICMPCodes = -1 12 | ) 13 | 14 | const ( 15 | minimumICMPType = 0 16 | maximumICMPType = 255 17 | minimumICMPCode = 0 18 | maximumICMPCode = 255 19 | ) 20 | 21 | // ICMPSet is a set of ICMP traffic, expressed as ICMP types and codes. ICMPSet can be used for either ICMPv4 or ICMPv6. For more information on how ICMPv4 and ICMPv6 use types and codes to describe IP traffic, see RFC 792 and RFC 4443, respectively. 22 | type ICMPSet struct { 23 | set Set 24 | } 25 | 26 | // NewEmptyICMPSet returns a new, empty ICMPSet. 27 | func NewEmptyICMPSet() ICMPSet { 28 | return ICMPSet{ 29 | set: newEmptySet(), 30 | } 31 | } 32 | 33 | // NewFullICMPSet returns a new, full ICMPSet. 34 | func NewFullICMPSet() ICMPSet { 35 | return ICMPSet{ 36 | set: newCompleteSet(), 37 | } 38 | } 39 | 40 | // NewICMPSetFromICMPType returns a new ICMPSet containing all codes gor a given type. 41 | func NewICMPSetFromICMPType(icmpType uint8) (ICMPSet, error) { 42 | if err := validateICMPType(icmpType); err != nil { 43 | return ICMPSet{}, fmt.Errorf("unable to use icmpType: %v", err) 44 | } 45 | 46 | startingICMPTypeCodeIndex := encodeICMPTypeCode(uint(icmpType), minimumICMPCode) 47 | endingICMPTypeCodeIndex := encodeICMPTypeCode(uint(icmpType), maximumICMPCode) 48 | 49 | set := newSetFromRange(startingICMPTypeCodeIndex, endingICMPTypeCodeIndex) 50 | 51 | return ICMPSet{ 52 | set: set, 53 | }, nil 54 | } 55 | 56 | // NewICMPSetFromICMPTypeCode returns a new ICMPSet containing the given type and code. 57 | func NewICMPSetFromICMPTypeCode(icmpType, icmpCode uint8) (ICMPSet, error) { 58 | if err := validateICMPType(icmpType); err != nil { 59 | return ICMPSet{}, fmt.Errorf("unable to use icmpType: %v", err) 60 | } 61 | 62 | if err := validateICMPCode(icmpCode); err != nil { 63 | return ICMPSet{}, fmt.Errorf("unable to use icmpCode: %v", err) 64 | } 65 | 66 | typeCodeIndex := encodeICMPTypeCode(uint(icmpType), uint(icmpCode)) 67 | set := newSetForSingleValue(typeCodeIndex) 68 | 69 | return ICMPSet{ 70 | set: set, 71 | }, nil 72 | } 73 | 74 | // Complete returns a boolean indicating whether or not the ICMPSet is complete. 75 | func (s ICMPSet) Complete() bool { 76 | return s.set.Complete() 77 | } 78 | 79 | // Empty returns a boolean indicating whether or not the ICMPSet is empty. 80 | func (s ICMPSet) Empty() bool { 81 | return s.set.Empty() 82 | } 83 | 84 | func allTypesV4(first, last ICMPTypeCode) (bool, string) { 85 | if first.icmpType == minimumICMPType && first.icmpCode == minimumICMPCode && last.icmpType == maximumICMPType && last.icmpCode == maximumICMPCode { 86 | return true, "ICMPv4 (all traffic)" 87 | } 88 | 89 | return false, "" 90 | } 91 | 92 | func allTypesV6(first, last ICMPTypeCode) (bool, string) { 93 | if first.icmpType == minimumICMPType && first.icmpCode == minimumICMPCode && last.icmpType == maximumICMPType && last.icmpCode == maximumICMPCode { 94 | return true, "ICMPv6 (all traffic)" 95 | } 96 | 97 | return false, "" 98 | } 99 | 100 | func allCodesForOneTypeV4(first, last ICMPTypeCode) (bool, string) { 101 | if first.icmpType != last.icmpType { 102 | return false, "" 103 | } 104 | 105 | // same type! 106 | 107 | if first.icmpCode == minimumICMPCode && last.icmpCode == maximumICMPCode { 108 | return true, fmt.Sprintf("ICMPv4 type \"%s\" (all traffic)", GetICMPv4TypeName(first.icmpType)) 109 | } 110 | 111 | return false, "" 112 | } 113 | 114 | func allCodesForOneTypeV6(first, last ICMPTypeCode) (bool, string) { 115 | if first.icmpType != last.icmpType { 116 | return false, "" 117 | } 118 | 119 | // same type! 120 | 121 | if first.icmpCode == minimumICMPCode && last.icmpCode == maximumICMPCode { 122 | return true, fmt.Sprintf("ICMPv6 type \"%s\" (all traffic)", GetICMPv6TypeName(first.icmpType)) 123 | } 124 | 125 | return false, "" 126 | } 127 | 128 | // RangeStringsV4 returns a slice of strings, where each string describes an individual ICMPv4 type component of the ICMPSet. 129 | func (s ICMPSet) RangeStringsV4() []string { 130 | var result []string 131 | 132 | for _, rangeItem := range s.set.ranges() { 133 | firstICMPTypeCode := decodeICMPTypeCode(rangeItem.first) 134 | lastICMPTypeCode := decodeICMPTypeCode(rangeItem.last) 135 | 136 | if isAllTypes, name := allTypesV4(firstICMPTypeCode, lastICMPTypeCode); isAllTypes { 137 | return []string{name} 138 | } 139 | 140 | if isAllCodesForType, name := allCodesForOneTypeV4(firstICMPTypeCode, lastICMPTypeCode); isAllCodesForType { 141 | result = append(result, name) 142 | continue 143 | } 144 | 145 | rangeString := fmt.Sprintf("%s - %s", firstICMPTypeCode.StringV4(), lastICMPTypeCode.StringV4()) 146 | result = append(result, rangeString) 147 | } 148 | 149 | return result 150 | } 151 | 152 | // RangeStringsV6 returns a slice of strings, where each string describes an individual ICMPv6 type component of the ICMPSet. 153 | func (s ICMPSet) RangeStringsV6() []string { 154 | var result []string 155 | 156 | for _, rangeItem := range s.set.ranges() { 157 | firstICMPTypeCode := decodeICMPTypeCode(rangeItem.first) 158 | lastICMPTypeCode := decodeICMPTypeCode(rangeItem.last) 159 | 160 | if isAllTypes, name := allTypesV6(firstICMPTypeCode, lastICMPTypeCode); isAllTypes { 161 | return []string{name} 162 | } 163 | 164 | if isAllCodesForType, name := allCodesForOneTypeV6(firstICMPTypeCode, lastICMPTypeCode); isAllCodesForType { 165 | result = append(result, name) 166 | continue 167 | } 168 | 169 | rangeString := fmt.Sprintf("%s - %s", firstICMPTypeCode.StringV6(), lastICMPTypeCode.StringV6()) 170 | result = append(result, rangeString) 171 | } 172 | 173 | return result 174 | } 175 | 176 | // StringV4 returns the string representation of the ICMPSet, assuming that the set describes ICMPv4 content. 177 | func (s ICMPSet) StringV4() string { 178 | if s.Empty() { 179 | return "[empty]" 180 | } 181 | return strings.Join(s.RangeStringsV4(), ", ") 182 | } 183 | 184 | // StringV6 returns the string representation of the ICMPSet, assuming that the set describes ICMPv6 content. 185 | func (s ICMPSet) StringV6() string { 186 | if s.Empty() { 187 | return "[empty]" 188 | } 189 | return strings.Join(s.RangeStringsV6(), ", ") 190 | } 191 | 192 | // Intersect takes the set intersection of two sets of ICMP traffic and returns the result. Because the ICMPSet type does not specify whether the content is ICMPv4 or ICMPv6, that check must be performed by the consumer. 193 | func (s ICMPSet) Intersect(other ICMPSet) ICMPSet { 194 | return ICMPSet{ 195 | set: s.set.intersect(other.set), 196 | } 197 | } 198 | 199 | // Merge takes the set merging of two sets of ICMP traffic and returns the result. Because the ICMPSet type does not specify whether the content is ICMPv4 or ICMPv6, that check must be performed by the consumer. 200 | func (s ICMPSet) Merge(other ICMPSet) ICMPSet { 201 | return ICMPSet{ 202 | set: s.set.merge(other.set), 203 | } 204 | } 205 | 206 | // Subtract takes the input set and subtracts it from the calling set of ICMP traffic and returns the result. Because the ICMPSet type does not specify whether the content is ICMPv4 or ICMPv6, that check must be performed by the consumer. 207 | func (s ICMPSet) Subtract(other ICMPSet) ICMPSet { 208 | return ICMPSet{ 209 | set: s.set.subtract(other.set), 210 | } 211 | } 212 | 213 | func validateICMPType(icmpType uint8) error { 214 | if icmpType < minimumICMPType || icmpType > maximumICMPType { 215 | return fmt.Errorf( 216 | "icmpType value %v is not valid, must be between %v and %v (inclusive)", 217 | icmpType, 218 | minimumICMPType, 219 | maximumICMPType, 220 | ) 221 | } 222 | 223 | return nil 224 | } 225 | 226 | func validateICMPCode(icmpCode uint8) error { 227 | if icmpCode < minimumICMPCode || icmpCode > maximumICMPCode { 228 | return fmt.Errorf( 229 | "icmpCode value %v is not valid, must be between %v and %v (inclusive)", 230 | icmpCode, 231 | minimumICMPCode, 232 | maximumICMPCode, 233 | ) 234 | } 235 | 236 | return nil 237 | } 238 | 239 | func encodeICMPTypeCode(icmpType, icmpCode uint) uint16 { 240 | const bitSize = 8 241 | 242 | return uint16((icmpType << bitSize) | icmpCode) 243 | } 244 | 245 | func decodeICMPTypeCode(value uint16) ICMPTypeCode { 246 | const bitSize = 8 247 | 248 | var icmpType = uint8((value & 0b1111111100000000) >> bitSize) 249 | 250 | var icmpCode = uint8(value & 0b0000000011111111) 251 | 252 | return ICMPTypeCode{icmpType, icmpCode} 253 | } 254 | -------------------------------------------------------------------------------- /reach/set/icmp_type_code.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import "fmt" 4 | 5 | var icmpv4TypeNames = map[uint8]string{ 6 | 0: "echo reply", 7 | 1: "reserved", 8 | 2: "reserved", 9 | 3: "destination unreachable", 10 | 4: "source quench", 11 | 5: "redirect message", 12 | 6: "alternate host address", 13 | 7: "reserved", 14 | 8: "echo request", 15 | 9: "router advertisement", 16 | 10: "router solicitation", 17 | 11: "time exceeded", 18 | 12: "parameter problem: bad IP header", 19 | 13: "timestamp", 20 | 14: "timestamp reply", 21 | 15: "information request", 22 | 16: "information reply", 23 | 17: "address mask request", 24 | 18: "address mask reply", 25 | 30: "information request", 26 | 31: "datagram conversion error", 27 | 32: "mobile host redirect", 28 | 33: "where are you", 29 | 34: "here I am", 30 | 35: "mobile registration request", 31 | 36: "mobile registration reply", 32 | 37: "domain name request", 33 | 38: "domain name reply", 34 | 39: "SKIP algorithm discovery protocol", 35 | 40: "Photuris, security failures", 36 | } 37 | 38 | var icmpv6TypeNames = map[uint8]string{ 39 | 1: "destination unreachable", 40 | 2: "packet too big", 41 | 3: "time exceeded", 42 | 4: "parameter problem", 43 | 100: "private experimentation", 44 | 101: "private experimentation", 45 | 128: "echo request", 46 | 129: "echo reply", 47 | 130: "multicast listener query (MLD)", 48 | 131: "multicast listener report (MLD)", 49 | 132: "multicast listener done (MLD)", 50 | 133: "router solicitation (NDP)", 51 | 134: "router advertisement (NDP)", 52 | 135: "neighbor solicitation (NDP)", 53 | 136: "neighbor advertisement (NDP)", 54 | 137: "redirect message", 55 | 138: "router renumbering", 56 | 139: "ICMP node information query", 57 | 140: "ICMP node information response", 58 | } 59 | 60 | // ICMPTypeCode represents a particular ICMP type and code. This type corresponds to the byte values from IP packets -- this type itself does not define whether the described network content is part of ICMPv4 or ICMPv6. 61 | type ICMPTypeCode struct { 62 | icmpType uint8 63 | icmpCode uint8 64 | } 65 | 66 | // StringV4 returns the string representation of the ICMPTypeCode, using the ICMPv4 definitions of the type and code values. 67 | func (i ICMPTypeCode) StringV4() string { 68 | typeName := GetICMPv4TypeName(i.icmpType) 69 | return fmt.Sprintf("%s (code %d)", typeName, i.icmpCode) 70 | } 71 | 72 | // StringV6 returns the string representation of the ICMPTypeCode, using the ICMPv6 definitions of the type and code values. 73 | func (i ICMPTypeCode) StringV6() string { 74 | typeName := GetICMPv6TypeName(i.icmpType) 75 | return fmt.Sprintf("%s (code %d)", typeName, i.icmpCode) 76 | } 77 | 78 | // GetICMPv4TypeName returns the ICMPv4 name for the given ICMP type value. 79 | func GetICMPv4TypeName(icmpType uint8) string { 80 | typeName, exists := icmpv4TypeNames[icmpType] 81 | if !exists { 82 | typeName = fmt.Sprintf("(unnamed ICMPv4 type: %d)", icmpType) 83 | } 84 | 85 | return typeName 86 | } 87 | 88 | // GetICMPv6TypeName returns the ICMPv6 name for the given ICMP type value. 89 | func GetICMPv6TypeName(icmpType uint8) string { 90 | typeName, exists := icmpv6TypeNames[icmpType] 91 | if !exists { 92 | typeName = fmt.Sprintf("(unnamed ICMPv6 type: %d)", icmpType) 93 | } 94 | 95 | return typeName 96 | } 97 | -------------------------------------------------------------------------------- /reach/set/port_set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | minimumPort = 0 10 | maximumPort = 65535 11 | ) 12 | 13 | // A PortSet represents a set of network traffic in terms of ports, suitable for describing TCP or UDP traffic. The PortSet type itself does not specify that the described content is for a particular IP protocol (like TCP or UDP). 14 | type PortSet struct { 15 | set Set 16 | } 17 | 18 | // NewEmptyPortSet returns a new, empty PortSet. 19 | func NewEmptyPortSet() PortSet { 20 | return PortSet{ 21 | set: newEmptySet(), 22 | } 23 | } 24 | 25 | // NewFullPortSet returns a new, full PortSet. 26 | func NewFullPortSet() PortSet { 27 | return PortSet{ 28 | set: newCompleteSet(), 29 | } 30 | } 31 | 32 | // NewPortSetFromRange returns a new PortSet containing all ports contained in the specified range, inclusively. 33 | func NewPortSetFromRange(lowPort, highPort uint16) (PortSet, error) { 34 | if err := validatePort(lowPort); err != nil { 35 | return PortSet{}, fmt.Errorf("unable to use lowPort: %v", err) 36 | } 37 | 38 | if err := validatePort(highPort); err != nil { 39 | return PortSet{}, fmt.Errorf("unable to use highPort: %v", err) 40 | } 41 | 42 | return PortSet{ 43 | set: newSetFromRange(lowPort, highPort), 44 | }, nil 45 | } 46 | 47 | // Complete returns a boolean indicating whether or not the PortSet is complete. 48 | func (s PortSet) Complete() bool { 49 | return s.set.Complete() 50 | } 51 | 52 | // Empty returns a boolean indicating whether or not the PortSet is empty. 53 | func (s PortSet) Empty() bool { 54 | return s.set.Empty() 55 | } 56 | 57 | // Intersect takes the set intersection of two sets of ports and returns the result. Because the PortSet type does not specify whether the content is TCP or UDP, that check must be performed by the consumer. 58 | func (s PortSet) Intersect(other PortSet) PortSet { 59 | return PortSet{ 60 | set: s.set.intersect(other.set), 61 | } 62 | } 63 | 64 | // Merge takes the set merging of two sets of ports and returns the result. Because the PortSet type does not specify whether the content is TCP or UDP, that check must be performed by the consumer. 65 | func (s PortSet) Merge(other PortSet) PortSet { 66 | return PortSet{ 67 | set: s.set.merge(other.set), 68 | } 69 | } 70 | 71 | // Subtract takes the "other" set and subtracts it from the calling set and returns the result. Because the PortSet type does not specify whether the content is TCP or UDP, that check must be performed by the consumer. 72 | func (s PortSet) Subtract(other PortSet) PortSet { 73 | return PortSet{ 74 | set: s.set.subtract(other.set), 75 | } 76 | } 77 | 78 | // RangeStrings returns a slice of strings, where each string describes a continuous range of ports within the PortSet. 79 | func (s PortSet) RangeStrings() []string { 80 | return s.set.rangeStrings() 81 | } 82 | 83 | // String returns the string representation of the PortSet. 84 | func (s PortSet) String() string { 85 | return s.set.String() 86 | } 87 | 88 | // MarshalJSON returns the JSON representation of the PortSet. 89 | func (s PortSet) MarshalJSON() ([]byte, error) { 90 | return json.Marshal(s) 91 | } 92 | 93 | func validatePort(port uint16) error { 94 | if port < minimumPort || port > maximumPort { 95 | return fmt.Errorf( 96 | "port number %v is not valid, must be between %v and %v", 97 | port, 98 | minimumPort, 99 | maximumPort, 100 | ) 101 | } 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /reach/set/range.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // Range defines a continuous, inclusive range of values. 9 | type Range struct { 10 | first uint16 11 | last uint16 12 | } 13 | 14 | // String returns the string representation of the Range. 15 | func (r Range) String() string { 16 | if r.first == r.last { 17 | return strconv.Itoa(int(r.first)) 18 | } 19 | 20 | return fmt.Sprintf("%d-%d", r.first, r.last) 21 | } 22 | -------------------------------------------------------------------------------- /reach/subject.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // SubjectRole specifies the role the subject plays in an analysis -- i.e. that this subject is the "source" or the "destination". 8 | type SubjectRole string 9 | 10 | // Allowed values for SubjectRole. 11 | const ( 12 | SubjectRoleNone SubjectRole = "none" 13 | SubjectRoleSource SubjectRole = "source" 14 | SubjectRoleDestination SubjectRole = "destination" 15 | ) 16 | 17 | // Common errors for the Subject type. 18 | const ( 19 | ErrSubjectPrefix = "subject creation error" 20 | ErrSubjectRoleValidation = "subject role must be 'source' or 'destination'" 21 | ErrSubjectIDValidation = "id must be a non-empty string" 22 | ) 23 | 24 | // A Subject is an entity about which a network traffic question is being asked. Reach analyses are conducted between "source" subjects and "destination" subjects. For example, when asking about network traffic allowed between instance A and instance B, instances A and B are the "subjects" of the analysis. 25 | type Subject struct { 26 | Domain string 27 | Kind string 28 | ID string 29 | Role SubjectRole 30 | } 31 | 32 | // SetRoleToSource sets the subject's role to "source". 33 | func (s *Subject) SetRoleToSource() { 34 | s.setRole(SubjectRoleSource) 35 | } 36 | 37 | // SetRoleToDestination sets the subject's role to "destination". 38 | func (s *Subject) SetRoleToDestination() { 39 | s.setRole(SubjectRoleDestination) 40 | } 41 | 42 | func (s *Subject) setRole(role SubjectRole) { 43 | if ValidSubjectRole(role) { 44 | s.Role = role 45 | } 46 | } 47 | 48 | // ValidSubjectRole returns a boolean indicating whether or not the specified subject role is valid. 49 | func ValidSubjectRole(role SubjectRole) bool { 50 | return role == SubjectRoleNone || role == SubjectRoleSource || role == SubjectRoleDestination 51 | } 52 | 53 | // NewSubjectError generates a new error related to a subject operation. 54 | func NewSubjectError(details string) error { 55 | return fmt.Errorf("%s: %s", ErrSubjectPrefix, details) 56 | } 57 | -------------------------------------------------------------------------------- /reach/testing.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | import "testing" 4 | 5 | // DiffErrorf provides a convenient way to output a difference between two values (such as between an expected value and an actual value) that caused a test to fail. 6 | func DiffErrorf(t *testing.T, item string, expected, actual interface{}) { 7 | t.Helper() 8 | t.Errorf("'%s' value differed from expected value...\n\nexpected:\n%v\n\nactual:\n%v\n\n", item, expected, actual) 9 | } 10 | -------------------------------------------------------------------------------- /reach/vector_analyzer.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | // A VectorAnalyzer can calculate analysis factors for a given network vector. 4 | type VectorAnalyzer interface { 5 | Factors(v NetworkVector) ([]Factor, NetworkVector, error) 6 | } 7 | -------------------------------------------------------------------------------- /reach/vector_discoverer.go: -------------------------------------------------------------------------------- 1 | package reach 2 | 3 | // A VectorDiscoverer can return all network vectors that exist between specified subjects. 4 | type VectorDiscoverer interface { 5 | Discover([]*Subject) ([]NetworkVector, error) 6 | } 7 | --------------------------------------------------------------------------------