From 577df602eb6849b96b70972981cf91c4039ae65c Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Thu, 20 Feb 2020 17:50:15 -0800 Subject: [PATCH] Initial commit --- .gitignore | 2 + .gitlab-ci.yml | 27 ++ CHANGELOG.md | 19 + Dockerfile | 15 + Gopkg.lock | 379 ++++++++++++++++ Gopkg.toml | 30 ++ Makefile | 24 + README.md | 92 ++++ go.mod | 21 + go.sum | 141 ++++++ main.go | 15 + pkg/cis/compute.go | 40 ++ pkg/cis/gke.go | 112 +++++ pkg/cis/iam.go | 82 ++++ pkg/cis/log_mon.go | 70 +++ pkg/cis/network.go | 58 +++ pkg/cis/recommendation.go | 123 +++++ pkg/cis/sql.go | 28 ++ pkg/cis/storage.go | 22 + pkg/client/client.go | 164 +++++++ pkg/client/client_test.go | 1 + pkg/client/compute.go | 421 ++++++++++++++++++ pkg/client/compute_test.go | 1 + pkg/client/container.go | 315 +++++++++++++ pkg/client/container_test.go | 1 + pkg/client/flags.go | 21 + pkg/client/iam.go | 214 +++++++++ pkg/client/iam_test.go | 1 + pkg/client/logging.go | 237 ++++++++++ pkg/client/logging_test.go | 1 + pkg/client/metrics.go | 82 ++++ pkg/client/network.go | 323 ++++++++++++++ pkg/client/network_test.go | 1 + pkg/client/oauth.go | 21 + pkg/client/oauth_test.go | 1 + pkg/client/projects.go | 77 ++++ pkg/client/projects_test.go | 1 + pkg/client/storage.go | 137 ++++++ pkg/client/storage_test.go | 1 + pkg/client/utils.go | 41 ++ pkg/client/utils_test.go | 1 + pkg/report/flags.go | 11 + pkg/report/pubsub.go | 76 ++++ pkg/report/pubsub_test.go | 1 + pkg/report/report.go | 89 ++++ pkg/report/report_test.go | 1 + pkg/report/reporter.go | 6 + pkg/report/stdout.go | 27 ++ pkg/report/stdout_test.go | 1 + pkg/resource/gcp/compute_address.go | 34 ++ pkg/resource/gcp/compute_address_test.go | 3 + pkg/resource/gcp/compute_firewall_rule.go | 58 +++ .../gcp/compute_firewall_rule_test.go | 1 + pkg/resource/gcp/compute_instance.go | 127 ++++++ pkg/resource/gcp/compute_instance_test.go | 1 + pkg/resource/gcp/compute_networks.go | 48 ++ pkg/resource/gcp/compute_networks_test.go | 1 + pkg/resource/gcp/compute_project.go | 35 ++ pkg/resource/gcp/compute_project_metadata.go | 88 ++++ .../gcp/compute_project_metadata_test.go | 1 + pkg/resource/gcp/compute_project_test.go | 1 + pkg/resource/gcp/compute_subnetwork.go | 44 ++ pkg/resource/gcp/compute_subnetwork_test.go | 1 + pkg/resource/gcp/container_cluster.go | 147 ++++++ pkg/resource/gcp/container_cluster_test.go | 1 + pkg/resource/gcp/container_nodepool.go | 64 +++ pkg/resource/gcp/container_nodepool_test.go | 1 + pkg/resource/gcp/flags.go | 37 ++ pkg/resource/gcp/iam_policy.go | 310 +++++++++++++ pkg/resource/gcp/iam_policy_test.go | 1 + pkg/resource/gcp/iam_serviceaccount.go | 68 +++ pkg/resource/gcp/iam_serviceaccount_test.go | 1 + pkg/resource/gcp/logging_metric.go | 27 ++ pkg/resource/gcp/logging_metric_test.go | 1 + pkg/resource/gcp/logging_sink.go | 25 ++ pkg/resource/gcp/logging_sink_test.go | 1 + pkg/resource/gcp/serviceusage.go | 29 ++ pkg/resource/gcp/serviceusage_test.go | 1 + pkg/resource/gcp/storage_bucket.go | 83 ++++ pkg/resource/gcp/storage_bucket_test.go | 146 ++++++ pkg/runner/runner.go | 136 ++++++ pkg/runner/runner_test.go | 1 + pkg/utils/env.go | 32 ++ pkg/utils/env_test.go | 51 +++ pkg/utils/flags.go | 7 + pkg/utils/timing.go | 17 + pkg/version/version.go | 86 ++++ pkg/version/version_test.go | 1 + 88 files changed, 5292 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/cis/compute.go create mode 100644 pkg/cis/gke.go create mode 100644 pkg/cis/iam.go create mode 100644 pkg/cis/log_mon.go create mode 100644 pkg/cis/network.go create mode 100644 pkg/cis/recommendation.go create mode 100644 pkg/cis/sql.go create mode 100644 pkg/cis/storage.go create mode 100644 pkg/client/client.go create mode 100644 pkg/client/client_test.go create mode 100644 pkg/client/compute.go create mode 100644 pkg/client/compute_test.go create mode 100644 pkg/client/container.go create mode 100644 pkg/client/container_test.go create mode 100644 pkg/client/flags.go create mode 100644 pkg/client/iam.go create mode 100644 pkg/client/iam_test.go create mode 100644 pkg/client/logging.go create mode 100644 pkg/client/logging_test.go create mode 100644 pkg/client/metrics.go create mode 100644 pkg/client/network.go create mode 100644 pkg/client/network_test.go create mode 100644 pkg/client/oauth.go create mode 100644 pkg/client/oauth_test.go create mode 100644 pkg/client/projects.go create mode 100644 pkg/client/projects_test.go create mode 100644 pkg/client/storage.go create mode 100644 pkg/client/storage_test.go create mode 100644 pkg/client/utils.go create mode 100644 pkg/client/utils_test.go create mode 100644 pkg/report/flags.go create mode 100644 pkg/report/pubsub.go create mode 100644 pkg/report/pubsub_test.go create mode 100644 pkg/report/report.go create mode 100644 pkg/report/report_test.go create mode 100644 pkg/report/reporter.go create mode 100644 pkg/report/stdout.go create mode 100644 pkg/report/stdout_test.go create mode 100644 pkg/resource/gcp/compute_address.go create mode 100644 pkg/resource/gcp/compute_address_test.go create mode 100644 pkg/resource/gcp/compute_firewall_rule.go create mode 100644 pkg/resource/gcp/compute_firewall_rule_test.go create mode 100644 pkg/resource/gcp/compute_instance.go create mode 100644 pkg/resource/gcp/compute_instance_test.go create mode 100644 pkg/resource/gcp/compute_networks.go create mode 100644 pkg/resource/gcp/compute_networks_test.go create mode 100644 pkg/resource/gcp/compute_project.go create mode 100644 pkg/resource/gcp/compute_project_metadata.go create mode 100644 pkg/resource/gcp/compute_project_metadata_test.go create mode 100644 pkg/resource/gcp/compute_project_test.go create mode 100644 pkg/resource/gcp/compute_subnetwork.go create mode 100644 pkg/resource/gcp/compute_subnetwork_test.go create mode 100644 pkg/resource/gcp/container_cluster.go create mode 100644 pkg/resource/gcp/container_cluster_test.go create mode 100644 pkg/resource/gcp/container_nodepool.go create mode 100644 pkg/resource/gcp/container_nodepool_test.go create mode 100644 pkg/resource/gcp/flags.go create mode 100644 pkg/resource/gcp/iam_policy.go create mode 100644 pkg/resource/gcp/iam_policy_test.go create mode 100644 pkg/resource/gcp/iam_serviceaccount.go create mode 100644 pkg/resource/gcp/iam_serviceaccount_test.go create mode 100644 pkg/resource/gcp/logging_metric.go create mode 100644 pkg/resource/gcp/logging_metric_test.go create mode 100644 pkg/resource/gcp/logging_sink.go create mode 100644 pkg/resource/gcp/logging_sink_test.go create mode 100644 pkg/resource/gcp/serviceusage.go create mode 100644 pkg/resource/gcp/serviceusage_test.go create mode 100644 pkg/resource/gcp/storage_bucket.go create mode 100644 pkg/resource/gcp/storage_bucket_test.go create mode 100644 pkg/runner/runner.go create mode 100644 pkg/runner/runner_test.go create mode 100644 pkg/utils/env.go create mode 100644 pkg/utils/env_test.go create mode 100644 pkg/utils/flags.go create mode 100644 pkg/utils/timing.go create mode 100644 pkg/version/version.go create mode 100644 pkg/version/version_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..419a650 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +audit.json +creds.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e816a1d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,27 @@ +image: docker:latest + +services: + - docker:dind + +variables: + DOCKER_DRIVER: overlay2 + GOPATH: /build + REPO_NAME: github.com/Unity-Technologies/nemesis + RUN_SRCCLR: 0 + +before_script: + # Install CA certs, openssl to https downloads, python for gcloud sdk + - apk add --update make ca-certificates openssl python + - update-ca-certificates + # Authorize the docker client with GCR + - echo $GCLOUD_SERVICE_KEY | docker login -u _json_key --password-stdin https://gcr.io + # Go to build directory + +stages: + - build + +build: + stage: build + script: + - | + make build push diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8385b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + + +## [0.1.0] - 2019-07-25 +### Added +- First release! +- Support for scanning GCP project(s) and measuring against the [GCP CIS Benchmark](https://www.cisecurity.org/benchmark/google_cloud_computing_platform/) + +### Changed +- N/A + +### Removed +- N/A diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01f5d1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.10.3 AS certs +RUN apk --no-cache add ca-certificates + +FROM golang:1.13.5 as builder +WORKDIR /app +COPY go.mod . +COPY go.sum . +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /nemesis . + +FROM scratch +COPY --from=builder /nemesis ./ +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +ENTRYPOINT ["./nemesis"] \ No newline at end of file diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..1ae85bc --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,379 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + digest = "1:03c669c2e391bf1c766e146c4cd5389c141056b71940d854a828580473b01d3a" + name = "cloud.google.com/go" + packages = [ + "compute/metadata", + "iam", + "internal/optional", + "internal/version", + "logging/apiv2", + "pubsub", + "pubsub/apiv1", + "pubsub/internal/distribution", + ] + pruneopts = "UT" + revision = "775730d6e48254a2430366162cf6298e5368833c" + version = "v0.39.0" + +[[projects]] + digest = "1:d6afaeed1502aa28e80a4ed0981d570ad91b2579193404256ce672ed0a609e0d" + name = "github.com/beorn7/perks" + packages = ["quantile"] + pruneopts = "UT" + revision = "4b2b341e8d7715fae06375aa633dbb6e91b3fb46" + version = "v1.0.0" + +[[projects]] + digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" + name = "github.com/davecgh/go-spew" + packages = ["spew"] + pruneopts = "UT" + revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" + version = "v1.1.1" + +[[projects]] + branch = "master" + digest = "1:1ba1d79f2810270045c328ae5d674321db34e3aae468eb4233883b473c5c0467" + name = "github.com/golang/glog" + packages = ["."] + pruneopts = "UT" + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + digest = "1:1882d3bab192c14c94b61781ff6d3965362f98527f895987793908304e90c118" + name = "github.com/golang/protobuf" + packages = [ + "proto", + "protoc-gen-go/descriptor", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/empty", + "ptypes/struct", + "ptypes/timestamp", + ] + pruneopts = "UT" + revision = "b5d812f8a3706043e23a9cd5babf2e5423744d30" + version = "v1.3.1" + +[[projects]] + digest = "1:f1f70abea1ab125d48396343b4c053f8fecfbdb943037bf3d29dc80c90fe60b3" + name = "github.com/googleapis/gax-go" + packages = ["v2"] + pruneopts = "UT" + revision = "beaecbbdd8af86aa3acf14180d53828ce69400b2" + version = "v2.0.4" + +[[projects]] + digest = "1:67474f760e9ac3799f740db2c489e6423a4cde45520673ec123ac831ad849cb8" + name = "github.com/hashicorp/golang-lru" + packages = ["simplelru"] + pruneopts = "UT" + revision = "7087cb70de9f7a8bc0a10c375cb0d2280a8edf9c" + version = "v0.5.1" + +[[projects]] + digest = "1:ff5ebae34cfbf047d505ee150de27e60570e8c394b3b8fdbb720ff6ac71985fc" + name = "github.com/matttproud/golang_protobuf_extensions" + packages = ["pbutil"] + pruneopts = "UT" + revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" + version = "v1.0.1" + +[[projects]] + digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + pruneopts = "UT" + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + digest = "1:287c515ccefca6ea7614a1b1dad119211510bf33ed01334646a9444db68d25e6" + name = "github.com/prometheus/client_golang" + packages = [ + "prometheus", + "prometheus/internal", + "prometheus/push", + ] + pruneopts = "UT" + revision = "50c4339db732beb2165735d2cde0bff78eb3c5a5" + version = "v0.9.3" + +[[projects]] + branch = "master" + digest = "1:2d5cd61daa5565187e1d96bae64dbbc6080dacf741448e9629c64fd93203b0d4" + name = "github.com/prometheus/client_model" + packages = ["go"] + pruneopts = "UT" + revision = "fd36f4220a901265f90734c3183c5f0c91daa0b8" + +[[projects]] + digest = "1:8dcedf2e8f06c7f94e48267dea0bc0be261fa97b377f3ae3e87843a92a549481" + name = "github.com/prometheus/common" + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model", + ] + pruneopts = "UT" + revision = "17f5ca1748182ddf24fc33a5a7caaaf790a52fcc" + version = "v0.4.1" + +[[projects]] + digest = "1:f8fac244ec2cb7daef48b0148dcf5a330ac7697fa83c2e1e78e65b21f7f43500" + name = "github.com/prometheus/procfs" + packages = [ + ".", + "internal/fs", + ] + pruneopts = "UT" + revision = "65bdadfa96aecebf4dcf888da995a29eab4fc964" + version = "v0.0.1" + +[[projects]] + digest = "1:972c2427413d41a1e06ca4897e8528e5a1622894050e2f527b38ddf0f343f759" + name = "github.com/stretchr/testify" + packages = ["assert"] + pruneopts = "UT" + revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" + version = "v1.3.0" + +[[projects]] + digest = "1:bf33f7cd985e8e62eeef3b1985ec48f0f274e4083fa811596aafaf3af2947e83" + name = "go.opencensus.io" + packages = [ + ".", + "internal", + "internal/tagencoding", + "metric/metricdata", + "metric/metricproducer", + "plugin/ocgrpc", + "plugin/ochttp", + "plugin/ochttp/propagation/b3", + "resource", + "stats", + "stats/internal", + "stats/view", + "tag", + "trace", + "trace/internal", + "trace/propagation", + "trace/tracestate", + ] + pruneopts = "UT" + revision = "9c377598961b706d1542bd2d84d538b5094d596e" + version = "v0.22.0" + +[[projects]] + branch = "master" + digest = "1:1b13e8770142a9251361b13a3b8b9b77296be6fa32856c937b346a45f93c845c" + name = "golang.org/x/net" + packages = [ + "context", + "context/ctxhttp", + "http/httpguts", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "trace", + ] + pruneopts = "UT" + revision = "f3200d17e092c607f615320ecaad13d87ad9a2b3" + +[[projects]] + branch = "master" + digest = "1:7cba983d19f4aa6a154d73268dcc67a66bcc24bd7ee1c1b09d448a721dea0d9f" + name = "golang.org/x/oauth2" + packages = [ + ".", + "google", + "internal", + "jws", + "jwt", + ] + pruneopts = "UT" + revision = "aaccbc9213b0974828f81aaac109d194880e3014" + +[[projects]] + branch = "master" + digest = "1:a2fc247e64b5dafd3251f12d396ec85f163d5bb38763c4997856addddf6e78d8" + name = "golang.org/x/sync" + packages = [ + "errgroup", + "semaphore", + ] + pruneopts = "UT" + revision = "112230192c580c3556b8cee6403af37a4fc5f28c" + +[[projects]] + branch = "master" + digest = "1:668e8c66b8895d69391429b0f64a72c35603c94f364c94d4e5fab5053d57a0b6" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "UT" + revision = "ad28b68e88f12448a1685d038ffea87bbbb34148" + +[[projects]] + digest = "1:8d8faad6b12a3a4c819a3f9618cb6ee1fa1cfc33253abeeea8b55336721e3405" + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/language", + "internal/language/compact", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable", + ] + pruneopts = "UT" + revision = "342b2e1fbaa52c93f31447ad2c6abc048c63e475" + version = "v0.3.2" + +[[projects]] + digest = "1:cd33295b974608edea17625187ae2fcfa9a61b0e814c814fbb0b410f3d9ab16b" + name = "google.golang.org/api" + packages = [ + "cloudresourcemanager/v1", + "compute/v1", + "container/v1", + "gensupport", + "googleapi", + "googleapi/internal/uritemplates", + "googleapi/transport", + "iam/v1", + "internal", + "iterator", + "option", + "serviceusage/v1", + "storage/v1", + "support/bundler", + "transport", + "transport/grpc", + "transport/http", + "transport/http/internal/propagation", + ] + pruneopts = "UT" + revision = "721295fe20d585ce7e948146f82188429d14da33" + version = "v0.5.0" + +[[projects]] + digest = "1:1366eff573b4e7adc862f31d01f31f20b3d9267031d45c5995da14a711e4add0" + name = "google.golang.org/appengine" + packages = [ + ".", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/socket", + "internal/urlfetch", + "socket", + "urlfetch", + ] + pruneopts = "UT" + revision = "4c25cacc810c02874000e4f7071286a8e96b2515" + version = "v1.6.0" + +[[projects]] + branch = "master" + digest = "1:cc9e0911a08dc947ce18bae75e6f29e87a1b2acef52927c8a7311ffec2e654a0" + name = "google.golang.org/genproto" + packages = [ + "googleapis/api", + "googleapis/api/annotations", + "googleapis/api/distribution", + "googleapis/api/label", + "googleapis/api/metric", + "googleapis/api/monitoredres", + "googleapis/iam/v1", + "googleapis/logging/type", + "googleapis/logging/v2", + "googleapis/pubsub/v1", + "googleapis/rpc/status", + "googleapis/type/expr", + "protobuf/field_mask", + ] + pruneopts = "UT" + revision = "fb225487d10142b5bcc35abfc6cb9a0609614976" + +[[projects]] + digest = "1:75fd7c63d317f4c60131dea3833934eb790ba067f90636fbcd51dbbd2ad57170" + name = "google.golang.org/grpc" + packages = [ + ".", + "balancer", + "balancer/base", + "balancer/roundrobin", + "binarylog/grpc_binarylog_v1", + "codes", + "connectivity", + "credentials", + "credentials/internal", + "credentials/oauth", + "encoding", + "encoding/proto", + "grpclog", + "internal", + "internal/backoff", + "internal/balancerload", + "internal/binarylog", + "internal/channelz", + "internal/envconfig", + "internal/grpcrand", + "internal/grpcsync", + "internal/syscall", + "internal/transport", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "resolver/dns", + "resolver/passthrough", + "stats", + "status", + "tap", + ] + pruneopts = "UT" + revision = "869adfc8d5a43efc0d05780ad109106f457f51e4" + version = "v1.21.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "cloud.google.com/go/logging/apiv2", + "cloud.google.com/go/pubsub", + "github.com/golang/glog", + "github.com/prometheus/client_golang/prometheus", + "github.com/prometheus/client_golang/prometheus/push", + "github.com/stretchr/testify/assert", + "golang.org/x/oauth2/google", + "google.golang.org/api/cloudresourcemanager/v1", + "google.golang.org/api/compute/v1", + "google.golang.org/api/container/v1", + "google.golang.org/api/iam/v1", + "google.golang.org/api/iterator", + "google.golang.org/api/serviceusage/v1", + "google.golang.org/api/storage/v1", + "google.golang.org/genproto/googleapis/logging/v2", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..d7072c2 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,30 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[prune] + go-tests = true + unused-packages = true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e7c3d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +name := $(shell basename "$(CURDIR)") +registry := $GCR_REGISTRY + +ifdef CI +git_hash := $(shell echo ${CI_COMMIT_SHA} | cut -c1-10 ) +git_branch := $(or ${CI_COMMIT_REF_NAME}, unknown) +git_tag := $(or ${CI_COMMIT_TAG}, ${CI_COMMIT_REF_NAME}, unknown) +else +git_hash := $(shell git rev-parse HEAD | cut -c1-10) +git_branch := $(shell git rev-parse --abbrev-ref HEAD || echo "unknown") +git_tag := $(or ${git_branch}, unknown) +endif + +build: + docker build \ + -t \ + $(registry)/$(name):${git_branch}-${git_hash} . + +push: + docker push \ + $(registry)/$(name):${git_branch}-${git_hash} + +test: + go test -count=1 -v ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b504437 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# nemesis + +Nemesis is a tool for auditing platform configurations for measuring compliance. It is meant as a read-only view into Cloud platforms such that necessary audits can be performed, which can result in actions to take. + +## Usage +You can install `nemesis` as a binary on your machine, or run it as a docker container. + +The following line demonstrates basic usage to invoke `nemesis` and output results into your terminal. This assumes that you have valid GCP credentials on the host you are running on: +``` +nemesis --project.filter="my-project" --reports.stdout.enable +``` + +You can utilize a service account credential file to perform `nemesis` runs as the service account user: +``` +# Set the environment variable that the Google Auth library expects +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json + +# Now run nemesis as the service account +nemesis --project.filter="my-awesome-project" --reports.stdout.enable +``` + +You can also combine `nemesis` with tools like `jq` to do parsing or formatting a JSON file: +``` +# Output a nemesis report to a local JSON file that is formatted in a readable way + +nemesis --project.filter="my-project" --reports.stdout.enable | jq . >> report.json +``` + +You can scan multiple projects in a single `nemesis` run using a simple regular expression: +``` +nemesis --project.filter="my-business-unit-projects-*" +``` + +`nemesis` reports can be directly shipped to a GCP Pub/Sub topic for direct ingestion into another system: +``` +nemesis --project.filter="my-project" --reports.pubsub.enable --reports.pubsub.project="my-reporting-project" --reports.pubsub.topic="nemesis-reports' +``` + +All flags for `nemesis` have an equivalent environment variable you can use for configuration. The table under Flags indicates the equivalencies: +``` +# Configure many settings before running +export NEMESIS_METRICS_ENABLED="true" +export NEMESIS_METRICS_GATEWAY="prometheus-pushgateway.example.com:9091" +export NEMESIS_PROJECT_FILTER="my-project" +export NEMESIS_ONLY_FAILURES="true" +export NEMESIS_ENABLE_STDOUT="true" + +# Now run the scan +nemesis +``` + + +## Flags +`nemesis` has a number of flags that can be invoked either using the command line flag or the equivalent environment variable. The following table describes their usage: + +| Flag | Environment Variable | Required | Description | Example Flag Usage | +|------|----------------------|----------|-------------|--------------------| +| project.filter | `NEMESIS_PROJECT_FILTER` | yes | (String) The project filter to perform audits on | `--project.filter="my-project"` | +| compute.instance.allow-ip-forwarding | `NEMESIS_COMPUTE_ALLOW_IP_FORWARDING` | no | (Bool) Indicate whether instances should be allowed to perform IP forwarding | `--compute.instance.allow-ip-forwarding` | +| compute.instance.allow-nat | `NEMESIS_COMPUTE_ALLOW_NAT` | no | (Bool) Indicate whether instances should be allowed to have external (NAT) IP addresses | `--compute.instance.allow-nat` | +| compute.instance.num-interfaces | `NEMESIS_COMPUTE_NUM_NICS` | no | (String) The number of network interfaces (NIC) that an instance should have (default 1) | `--compute.instance.num-interfaces=1` | +| container.oauth-scopes | `NEMESIS_CONTAINER_OAUTHSCOPES ` | no | (String) A comma-seperated list of OAuth scopes to allow for GKE clusters (default
"https://www.googleapis.com/auth/devstorage.read_only,
https://www.googleapis.com/auth/logging.write,
https://www.googleapis.com/auth/monitoring,
https://www.googleapis.com/auth/servicecontrol,
https://www.googleapis.com/auth/service.management.readonly,
https://www.googleapis.com/auth/trace.append") | `--container.oauth-scopes="..."` | +| iam.sa-key-expiration-time | `NEMESIS_IAM_SA_KEY_EXPIRATION_TIME` | no | (String) The time in days to allow service account keys to live before being rotated (default "90") | `--iam.sa-key-expiration-time="90"` | +| iam.user-domains | `NEMESIS_IAM_USERDOMAINS` | no | (String) A comma-separated list of domains to allow users from | `--iam.user-domains="google.com"` | +| metrics.enabled | `NEMESIS_METRICS_ENABLED` | no | (Boolean) Enable Prometheus metrics | `--metrics.enabled` | +| metrics.gateway | `NEMESIS_METRICS_GATEWAY` | no | (String) Prometheus metrics Push Gateway (default "127.0.0.1:9091") | `--metrics.gateway="10.0.160.12:9091"` | +| reports.only-failures | `NEMESIS_ONLY_FAILURES` | no | (Boolean) Limit output of controls to only failed controls | `--reports.only-failures` | +| reports.stdout.enable | `NEMESIS_ENABLE_STDOUT` | no | (Boolean) Enable outputting report via stdout | `--reports.stdout.enable` | +| reports.pubsub.enable | `NEMESIS_ENABLE_PUBSUB` | no | (Boolean) Enable outputting report via Google Pub/Sub | `--reports.pubsub.enable` | +| reports.pubsub.project | `NEMESIS_PUBSUB_PROJECT` | no | (Boolean) Indicate which GCP project to output Pub/Sub reports to | `--reports.pubsub.project="my-project"` | +| reports.pubsub.topic | `NEMESIS_PUBSUB_TOPIC` | no | (Boolean) Indicate which topic to output Pub/Sub reports to (default "nemesis") | `--reports.pubsub.topic="nemesis-reports"` | + +## Motivation + +`nemesis` was created out of a need to generate compliance and auditing reports quickly and in consumable formats. This tool helps audit against GCP security standards and best practices. We implement, as a baseline security metric: +* [CIS Controls for GCP](https://www.cisecurity.org/benchmark/google_cloud_computing_platform/) +* [GKE Hardening Guidelines](https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster) +* [Default Project Metadata](https://cloud.google.com/compute/docs/storing-retrieving-metadata#default) + +We strive to encourage best practices in our environment for the following GCP services: +* [Identity and Access Management (IAM)](https://cloud.google.com/iam/docs/using-iam-securely) +* [Google Cloud Storage (GCS)](https://cloud.google.com/storage/docs/access-control/using-iam-permissions) +* [Google Compute Engine (GCE)](https://cloud.google.com/compute/docs/access/) +* [Google Kubernetes Engine (GKE)](https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-admin-overview#configuring_cluster_security) + +## Maintainers + +@TaylorMutch + +## Contributions + +See Contributions \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..62af85b --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/Unity-Technologies/nemesis + +go 1.13 + +require ( + cloud.google.com/go v0.39.0 + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b + github.com/prometheus/client_golang v0.9.3 + github.com/prometheus/common v0.4.1 // indirect + github.com/prometheus/procfs v0.0.1 // indirect + github.com/stretchr/testify v1.3.0 + go.opencensus.io v0.22.0 // indirect + golang.org/x/net v0.0.0-20190522155817-f3200d17e092 // indirect + golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 + golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect + golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 // indirect + google.golang.org/api v0.5.0 + google.golang.org/appengine v1.6.0 // indirect + google.golang.org/genproto v0.0.0-20190530194941-fb225487d101 + google.golang.org/grpc v1.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a17c96 --- /dev/null +++ b/go.sum @@ -0,0 +1,141 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.39.0 h1:UgQP9na6OTfp4dsAiz/eFpFA1C6tPdH5wiRdi19tuMw= +cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/googleapis/gax-go/v2 v2.0.4 h1:hU4mGcQI4DaAYW+IbTun+2qEZVFxK0ySjQLTbS0VQKc= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.1 h1:Vb1OE5ZDNKF3yhna6/G+5pHqADNm4I8hUoHj7YQhbZk= +github.com/prometheus/procfs v0.0.1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 h1:xFEXbcD0oa/xhqQmMXztdZ0bWvexAWds+8c1gRN8nu0= +golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 h1:R4dVlxdmKenVdMRS/tTspEpSTRWINYrHD8ySIU9yCIU= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/api v0.5.0 h1:lj9SyhMzyoa38fgFF0oO2T6pjs5IzkLPKfVtxpyCRMM= +google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101 h1:wuGevabY6r+ivPNagjUXGGxF+GqgMd+dBhjsxW4q9u4= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0 h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b365e40 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "flag" + + "github.com/Unity-Technologies/nemesis/pkg/runner" +) + +func main() { + flag.Parse() + audit := runner.NewAudit() + audit.Setup() + audit.Execute() + audit.Report() +} diff --git a/pkg/cis/compute.go b/pkg/cis/compute.go new file mode 100644 index 0000000..190e5a2 --- /dev/null +++ b/pkg/cis/compute.go @@ -0,0 +1,40 @@ +package cis + +var ( + compute1 = Recommendation{ + Name: "Ensure that instances are not configured to use the default service account with full access to all Cloud APIs", + CisID: "4.1", + Scored: true, + Level: 1, + } + compute2 = Recommendation{ + Name: "Ensure 'Block Project-wide SSH keys' enabled for VM instances", + CisID: "4.2", + Scored: false, + Level: 1, + } + compute3 = Recommendation{ + Name: "Ensure oslogin is enabled for a Project", + CisID: "4.3", + Scored: true, + Level: 1, + } + compute4 = Recommendation{ + Name: "Ensure 'Enable connecting to serial ports' is not enabled for VM Instance", + CisID: "4.4", + Scored: true, + Level: 1, + } + compute5 = Recommendation{ + Name: "Ensure that IP forwarding is not enabled on Instances", + CisID: "4.5", + Scored: true, + Level: 1, + } + compute6 = Recommendation{ + Name: "Ensure VM disks for critical VMs are encrypted with Customer-Supplied Encryption Keys (CSEK)", + CisID: "4.6", + Scored: true, + Level: 2, + } +) diff --git a/pkg/cis/gke.go b/pkg/cis/gke.go new file mode 100644 index 0000000..84814bf --- /dev/null +++ b/pkg/cis/gke.go @@ -0,0 +1,112 @@ +package cis + +var ( + gke1 = Recommendation{ + Name: "Ensure Stackdriver Logging is set to Enabled on Kubernetes Engine Clusters", + CisID: "7.1", + Scored: true, + Level: 1, + } + gke2 = Recommendation{ + Name: "Ensure Stackdriver Monitoring is set to Enabled on Kubernetes Engine Clusters", + CisID: "7.2", + Scored: false, + Level: 1, + } + gke3 = Recommendation{ + Name: "Ensure Legacy Authorization is set to Disabled on Kubernetes Engine Clusters", + CisID: "7.3", + Scored: true, + Level: 1, + } + gke4 = Recommendation{ + Name: "Ensure Master authorized networks is set to Enabled on Kubernetes Engine Clusters", + CisID: "7.4", + Scored: false, + Level: 1, + } + gke5 = Recommendation{ + Name: "Ensure Kubernetes Clusters are configured with Labels", + CisID: "7.5", + Scored: false, + Level: 1, + } + gke6 = Recommendation{ + Name: "Ensure Kubernetes web UI / Dashboard is disabled", + CisID: "7.6", + Scored: true, + Level: 1, + } + gke7 = Recommendation{ + Name: "Ensure Automatic node repair is enabled for Kubernetes Clusters", + CisID: "7.7", + Scored: true, + Level: 1, + } + gke8 = Recommendation{ + Name: "Ensure Automatic node upgrades is enabled on Kubernetes Engine Clusters nodes", + CisID: "7.8", + Scored: true, + Level: 1, + } + gke9 = Recommendation{ + Name: "Ensure Container-Optimized OS (COS) is used for Kubernetes Engine Clusters Node image", + CisID: "7.9", + Scored: false, + Level: 2, + } + gke10 = Recommendation{ + Name: "Ensure Basic Authentication is disabled on Kubernetes Engine Clusters", + CisID: "7.10", + Scored: true, + Level: 1, + } + gke11 = Recommendation{ + Name: "Ensure Network policy is enabled on Kubernetes Engine Clusters", + CisID: "7.11", + Scored: true, + Level: 1, + } + gke12 = Recommendation{ + Name: "Ensure Kubernetes Cluster is created with Client Certificate enabled", + CisID: "7.12", + Scored: true, + Level: 1, + } + gke13 = Recommendation{ + Name: "Ensure Kubernetes Cluster is created with Alias IP ranges enabled", + CisID: "7.13", + Scored: true, + Level: 1, + } + gke14 = Recommendation{ + Name: "Ensure PodSecurityPolicy controller is enabled on the Kubernetes Engine Clusters", + CisID: "7.14", + Scored: false, + Level: 1, + } + gke15 = Recommendation{ + Name: "Ensure Kubernetes Cluster is created with Private cluster enabled", + CisID: "7.15", + Scored: true, + Level: 1, + } + gke16 = Recommendation{ + Name: "Ensure Private Google Access is set on Kubernetes Engine Cluster Subnets", + CisID: "7.16", + Scored: true, + Level: 1, + } + gke17 = Recommendation{ + Name: "Ensure default Service account is not used for Project access in Kubernetes Clusters", + CisID: "7.17", + Scored: true, + Level: 1, + } + gke18 = Recommendation{ + Name: "Ensure Kubernetes Clusters created with limited service account Access scopes for Project access", + CisID: "7.18", + Scored: true, + Level: 1, + } +) diff --git a/pkg/cis/iam.go b/pkg/cis/iam.go new file mode 100644 index 0000000..e14a6f8 --- /dev/null +++ b/pkg/cis/iam.go @@ -0,0 +1,82 @@ +package cis + +var ( + iam1 = Recommendation{ + Name: "Ensure that corporate login credentials are used instead of Gmail accounts", + CisID: "1.1", + Scored: true, + Level: 1, + } + iam2 = Recommendation{ + Name: "Ensure that multi-factor authentication is enabled for all non- service accounts", + CisID: "1.2", + Scored: false, + Level: 1, + } + iam3 = Recommendation{ + Name: "Ensure that there are only GCP-managed service account keys for each service account", + CisID: "1.3", + Scored: true, + Level: 1, + } + iam4 = Recommendation{ + Name: "Ensure that ServiceAccount has no Admin privileges", + CisID: "1.4", + Scored: true, + Level: 1, + } + iam5 = Recommendation{ + Name: "Ensure that IAM users are not assigned Service Account User role at project level", + CisID: "1.5", + Scored: true, + Level: 1, + } + iam6 = Recommendation{ + Name: "Ensure user-managed/external keys for service accounts are rotated every 90 days or less", + CisID: "1.6", + Scored: true, + Level: 1, + } + iam7 = Recommendation{ + Name: "Ensure that Separation of duties is enforced while assigning service account related roles to users", + CisID: "1.7", + Scored: false, + Level: 2, + } + iam8 = Recommendation{ + Name: "Ensure Encryption keys are rotated within a period of 365 days", + CisID: "1.8", + Scored: true, + Level: 1, + } + iam9 = Recommendation{ + Name: "Ensure that Separation of duties is enforced while assigning KMS related roles to users", + CisID: "1.9", + Scored: true, + Level: 2, + } + iam10 = Recommendation{ + Name: "Ensure API keys are not created for a project", + CisID: "1.10", + Scored: false, + Level: 2, + } + iam11 = Recommendation{ + Name: "Ensure API keys are restricted to use by only specified Hosts and Apps", + CisID: "1.11", + Scored: false, + Level: 1, + } + iam12 = Recommendation{ + Name: "Ensure API keys are restricted to only APIs that application needs access", + CisID: "1.12", + Scored: false, + Level: 1, + } + iam13 = Recommendation{ + Name: "Ensure API keys are rotated every 90 days", + CisID: "1.13", + Scored: true, + Level: 1, + } +) diff --git a/pkg/cis/log_mon.go b/pkg/cis/log_mon.go new file mode 100644 index 0000000..1a6130b --- /dev/null +++ b/pkg/cis/log_mon.go @@ -0,0 +1,70 @@ +package cis + +var ( + logmon1 = Recommendation{ + Name: "Ensure that Cloud Audit Logging is configured properly across all services and all users from a project", + CisID: "2.1", + Scored: true, + Level: 1, + } + logmon2 = Recommendation{ + Name: "Ensure that sinks are configured for all Log entries", + CisID: "2.2", + Scored: true, + Level: 1, + } + logmon3 = Recommendation{ + Name: "Ensure that object versioning is enabled on log-buckets", + CisID: "2.3", + Scored: true, + Level: 1, + } + logmon4 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for Project Ownership assignments/changes", + CisID: "2.4", + Scored: true, + Level: 1, + } + logmon5 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for Audit Configuration Changes", + CisID: "2.5", + Scored: true, + Level: 1, + } + logmon6 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for Custom Role changes", + CisID: "2.6", + Scored: true, + Level: 2, + } + logmon7 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for VPC Network Firewall rule changes", + CisID: "2.7", + Scored: true, + Level: 1, + } + logmon8 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for VPC network route changes", + CisID: "2.8", + Scored: true, + Level: 1, + } + logmon9 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for VPC network changes", + CisID: "2.9", + Scored: true, + Level: 1, + } + logmon10 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for Cloud Storage IAM permission changes", + CisID: "2.10", + Scored: true, + Level: 1, + } + logmon11 = Recommendation{ + Name: "Ensure log metric filter and alerts exists for SQL instance configuration changes", + CisID: "2.11", + Scored: true, + Level: 1, + } +) diff --git a/pkg/cis/network.go b/pkg/cis/network.go new file mode 100644 index 0000000..60719e3 --- /dev/null +++ b/pkg/cis/network.go @@ -0,0 +1,58 @@ +package cis + +var ( + network1 = Recommendation{ + Name: "Ensure the default network does not exist in a project", + CisID: "3.1", + Scored: true, + Level: 1, + } + network2 = Recommendation{ + Name: "Ensure legacy networks does not exists for a project", + CisID: "3.2", + Scored: true, + Level: 1, + } + network3 = Recommendation{ + Name: "Ensure that DNSSEC is enabled for Cloud DNS", + CisID: "3.3", + Scored: false, + Level: 1, + } + network4 = Recommendation{ + Name: "Ensure that RSASHA1 is not used for key-signing key in Cloud DNS DNSSEC", + CisID: "3.4", + Scored: false, + Level: 1, + } + network5 = Recommendation{ + Name: "Ensure that RSASHA1 is not used for zone-signing key in Cloud DNS DNSSEC", + CisID: "3.5", + Scored: false, + Level: 1, + } + network6 = Recommendation{ + Name: "Ensure that SSH access is restricted from the internet", + CisID: "3.6", + Scored: true, + Level: 2, + } + network7 = Recommendation{ + Name: "Ensure that RDP access is restricted from the internet", + CisID: "3.7", + Scored: true, + Level: 2, + } + network8 = Recommendation{ + Name: "Ensure Private Google Access is enabled for all subnetwork in VPC Network", + CisID: "3.8", + Scored: true, + Level: 2, + } + network9 = Recommendation{ + Name: "Ensure VPC Flow logs is enabled for every subnet in VPC Network", + CisID: "3.9", + Scored: true, + Level: 1, + } +) diff --git a/pkg/cis/recommendation.go b/pkg/cis/recommendation.go new file mode 100644 index 0000000..e2b9201 --- /dev/null +++ b/pkg/cis/recommendation.go @@ -0,0 +1,123 @@ +// Package cis is a schema for organizing CIS controls for Google Cloud +package cis + +import ( + "encoding/json" + "fmt" +) + +// Recommendation is a CIS recommendation for GCP +type Recommendation struct { + // The name of the CIS recommendation + Name string `json:"name"` + + // Indicates whether compliance with the recommendation should + // be attributeable to the overall compliance of the relevant resource + Scored bool `json:"scored"` + + // The CIS identifier for the recommendation. They are formatted in a major-minor + // string (E.g. "1.12") + CisID string `json:"cisId"` + + // The CIS level for the recommendation. + Level int `json:"level"` +} + +var ( + // Registry is the registry of CIS recommendations + Registry = make(map[string]Recommendation, 1) +) + +// Marshal returns the JSON formatted bytes for a recommendation +func (r *Recommendation) Marshal() ([]byte, error) { + return json.Marshal(&r) +} + +// Format returns the fully formatted CIS descriptive name +func (r *Recommendation) Format() string { + score := "Scored" + if !r.Scored { + score = "Not Scored" + } + return fmt.Sprintf("CIS %v - %v (%v)", r.CisID, r.Name, score) +} + +func init() { + // IAM controls + Registry[iam1.CisID] = iam1 + Registry[iam2.CisID] = iam2 + Registry[iam3.CisID] = iam3 + Registry[iam4.CisID] = iam4 + Registry[iam5.CisID] = iam5 + Registry[iam6.CisID] = iam6 + Registry[iam7.CisID] = iam7 + Registry[iam8.CisID] = iam8 + Registry[iam9.CisID] = iam9 + Registry[iam10.CisID] = iam10 + Registry[iam11.CisID] = iam11 + Registry[iam12.CisID] = iam12 + Registry[iam13.CisID] = iam13 + + // Logging & Monitoring + Registry[logmon1.CisID] = logmon1 + Registry[logmon2.CisID] = logmon2 + Registry[logmon3.CisID] = logmon3 + Registry[logmon4.CisID] = logmon4 + Registry[logmon5.CisID] = logmon5 + Registry[logmon6.CisID] = logmon6 + Registry[logmon7.CisID] = logmon7 + Registry[logmon8.CisID] = logmon8 + Registry[logmon9.CisID] = logmon9 + Registry[logmon10.CisID] = logmon10 + Registry[logmon11.CisID] = logmon11 + + // Networking + Registry[network1.CisID] = network1 + Registry[network2.CisID] = network2 + Registry[network3.CisID] = network3 + Registry[network4.CisID] = network4 + Registry[network5.CisID] = network5 + Registry[network6.CisID] = network6 + Registry[network7.CisID] = network7 + Registry[network8.CisID] = network8 + Registry[network9.CisID] = network9 + + // VM & Compute + Registry[compute1.CisID] = compute1 + Registry[compute2.CisID] = compute2 + Registry[compute3.CisID] = compute3 + Registry[compute4.CisID] = compute4 + Registry[compute5.CisID] = compute5 + Registry[compute6.CisID] = compute6 + + // GCS Storage + Registry[storage1.CisID] = storage1 + Registry[storage2.CisID] = storage2 + Registry[storage3.CisID] = storage3 + + // SQL + Registry[sql1.CisID] = sql1 + Registry[sql2.CisID] = sql2 + Registry[sql3.CisID] = sql3 + Registry[sql4.CisID] = sql4 + + // Kubernetes Engine + Registry[gke1.CisID] = gke1 + Registry[gke2.CisID] = gke2 + Registry[gke3.CisID] = gke3 + Registry[gke4.CisID] = gke4 + Registry[gke5.CisID] = gke5 + Registry[gke6.CisID] = gke6 + Registry[gke7.CisID] = gke7 + Registry[gke8.CisID] = gke8 + Registry[gke9.CisID] = gke9 + Registry[gke10.CisID] = gke10 + Registry[gke11.CisID] = gke11 + Registry[gke12.CisID] = gke12 + Registry[gke13.CisID] = gke13 + Registry[gke14.CisID] = gke14 + Registry[gke15.CisID] = gke15 + Registry[gke16.CisID] = gke16 + Registry[gke17.CisID] = gke17 + Registry[gke18.CisID] = gke18 +} diff --git a/pkg/cis/sql.go b/pkg/cis/sql.go new file mode 100644 index 0000000..feb62f5 --- /dev/null +++ b/pkg/cis/sql.go @@ -0,0 +1,28 @@ +package cis + +var ( + sql1 = Recommendation{ + Name: "Ensure that Cloud SQL database instance requires all incoming connections to use SSL", + CisID: "6.1", + Scored: true, + Level: 1, + } + sql2 = Recommendation{ + Name: "Ensure that Cloud SQL database Instances are not open to the world", + CisID: "6.2", + Scored: true, + Level: 1, + } + sql3 = Recommendation{ + Name: "Ensure that MySql database instance does not allow anyone to connect with administrative privileges", + CisID: "6.3", + Scored: true, + Level: 1, + } + sql4 = Recommendation{ + Name: "Ensure that MySQL Database Instance does not allows root login from any Host", + CisID: "6.4", + Scored: true, + Level: 1, + } +) diff --git a/pkg/cis/storage.go b/pkg/cis/storage.go new file mode 100644 index 0000000..df14ec7 --- /dev/null +++ b/pkg/cis/storage.go @@ -0,0 +1,22 @@ +package cis + +var ( + storage1 = Recommendation{ + Name: "Ensure that Cloud Storage bucket is not anonymously or publicly accessible", + CisID: "5.1", + Scored: true, + Level: 1, + } + storage2 = Recommendation{ + Name: "Ensure that there are no publicly accessible objects in storage buckets", + CisID: "5.2", + Scored: false, + Level: 1, + } + storage3 = Recommendation{ + Name: "Ensure that logging is enabled for Cloud storage buckets", + CisID: "5.3", + Scored: true, + Level: 1, + } +) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..fc28738 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,164 @@ +package client + +import ( + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + + "github.com/golang/glog" + + "context" + + logging "cloud.google.com/go/logging/apiv2" + push "github.com/prometheus/client_golang/prometheus/push" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" + compute "google.golang.org/api/compute/v1" + container "google.golang.org/api/container/v1" + iam "google.golang.org/api/iam/v1" + serviceusage "google.golang.org/api/serviceusage/v1" + storage "google.golang.org/api/storage/v1" +) + +// Client is the client used for auditing Google Cloud Compute Engine resources +type Client struct { + + // API clients + computeClient *compute.Service + cloudResourceClient *cloudresourcemanager.Service + storageClient *storage.Service + containerClient *container.Service + serviceusageClient *serviceusage.Service + iamClient *iam.Service + logConfigClient *logging.ConfigClient + logMetricClient *logging.MetricsClient + + // Root project + resourceprojects []*cloudresourcemanager.Project + + // Resources + services map[string][]*gcp.ServiceAPIResource + computeprojects []*gcp.ComputeProjectResource + computeMetadatas map[string]*gcp.ComputeProjectMetadataResource + buckets map[string][]*gcp.StorageBucketResource + instances map[string][]*gcp.ComputeInstanceResource + + // Container Resources + clusters map[string][]*gcp.ContainerClusterResource + nodepools map[string][]*gcp.ContainerNodePoolResource + + // Compute Network Resources + networks map[string][]*gcp.ComputeNetworkResource + subnetworks map[string][]*gcp.ComputeSubnetworkResource + firewalls map[string][]*gcp.ComputeFirewallRuleResource + addresses map[string][]*gcp.ComputeAddressResource + + // IAM Resources + policies map[string]*gcp.IamPolicyResource + serviceaccounts map[string][]*gcp.IamServiceAccountResource + + // Logging resources + logSinks map[string][]*gcp.LoggingSinkResource + logMetrics map[string][]*gcp.LoggingMetricResource + + // Metrics pusher + pusher *push.Pusher + metricsArePushed bool +} + +// New returns a new wrk conforming to the worker.W interface +func New() *Client { + var cc *compute.Service + var crm *cloudresourcemanager.Service + var cs *storage.Service + var con *container.Service + var su *serviceusage.Service + var i *iam.Service + var lc *logging.ConfigClient + var lm *logging.MetricsClient + + c := new(Client) + ctx := context.Background() + + // Create compute client + cc, err := compute.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create Google Cloud Engine client: %v", err) + } + + // Create cloudresourcemanager client + crm, err = cloudresourcemanager.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create Google Cloud Resource Manager client: %v", err) + } + + // Create storage client + cs, err = storage.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create Google Cloud Storage client: %v", err) + } + + // Create container client + con, err = container.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create Google Container client: %v", err) + } + + // Create serviceusage client + su, err = serviceusage.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create Google Service Usage client: %v", err) + } + + i, err = iam.NewService(ctx) + if err != nil { + glog.Fatalf("Failed to create IAM client: %v", err) + } + + lc, err = logging.NewConfigClient(ctx) + if err != nil { + glog.Fatalf("Failed to create logging config client: %v", err) + } + + lm, err = logging.NewMetricsClient(ctx) + if err != nil { + glog.Fatalf("Failed to create logging metrics client: %v", err) + } + + c.computeClient = cc + c.cloudResourceClient = crm + c.storageClient = cs + c.containerClient = con + c.serviceusageClient = su + c.iamClient = i + c.logConfigClient = lc + c.logMetricClient = lm + + // Services + c.resourceprojects = []*cloudresourcemanager.Project{} + c.computeprojects = []*gcp.ComputeProjectResource{} + + // Resources + c.services = make(map[string][]*gcp.ServiceAPIResource, 1) + c.computeMetadatas = make(map[string]*gcp.ComputeProjectMetadataResource, 1) + c.buckets = make(map[string][]*gcp.StorageBucketResource, 1) + c.instances = make(map[string][]*gcp.ComputeInstanceResource, 1) + c.clusters = make(map[string][]*gcp.ContainerClusterResource, 1) + c.nodepools = make(map[string][]*gcp.ContainerNodePoolResource, 1) + + // Compute networking resources + c.networks = make(map[string][]*gcp.ComputeNetworkResource, 1) + c.subnetworks = make(map[string][]*gcp.ComputeSubnetworkResource, 1) + c.firewalls = make(map[string][]*gcp.ComputeFirewallRuleResource, 1) + c.addresses = make(map[string][]*gcp.ComputeAddressResource, 1) + + // IAM resources + c.policies = make(map[string]*gcp.IamPolicyResource, 1) + c.serviceaccounts = make(map[string][]*gcp.IamServiceAccountResource, 1) + + // Logging resources + c.logSinks = make(map[string][]*gcp.LoggingSinkResource, 1) + c.logMetrics = make(map[string][]*gcp.LoggingMetricResource, 1) + + // Configure metrics + c.pusher = configureMetrics() + + return c +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/compute.go b/pkg/client/compute.go new file mode 100644 index 0000000..c4444a3 --- /dev/null +++ b/pkg/client/compute.go @@ -0,0 +1,421 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" + compute "google.golang.org/api/compute/v1" +) + +func (c *Client) getZoneNames() ([]string, error) { + + // Get the zones from the first project. Should be the same for all projects + zoneNames := make([]string, 0) + var zones *compute.ZoneList + var err error + for i := 0; i < len(c.resourceprojects); i++ { + + zones, err = c.computeClient.Zones.List(c.resourceprojects[i].ProjectId).Do() + if err == nil { + // We got a valid list of zones, so skip + break + } + } + + if zones == nil { + err = fmt.Errorf("Error retrieving zones list from any project: %v", err) + return nil, err + } + + for _, z := range zones.Items { + zoneNames = append(zoneNames, z.Name) + } + + return zoneNames, nil +} + +func (c *Client) getRegionNames() ([]string, error) { + + // Get the regions from the first project. Should be the same for all projects + regionNames := make([]string, 0) + var regions *compute.RegionList + var err error + for i := 0; i < len(c.resourceprojects); i++ { + + regions, err = c.computeClient.Regions.List(c.resourceprojects[i].ProjectId).Do() + if err == nil { + break + } + } + + if regions == nil { + err = fmt.Errorf("Error retrieving regions list from any project: %v", err) + return nil, err + } + + for _, r := range regions.Items { + regionNames = append(regionNames, r.Name) + } + + return regionNames, nil +} + +// GetComputeResources launches the process retrieving compute resources +func (c *Client) GetComputeResources() error { + + defer utils.Elapsed("GetComputeResources")() + + // Get list of all projects. + projects := c.resourceprojects + + zoneNames, err := c.getZoneNames() + if err != nil { + glog.Fatalf("%v", err) + } + + // Create a worker pool for querying zones a bit faster + zoneWorker := func(projectID string, id int, zones <-chan string, results chan<- []*gcp.ComputeInstanceResource) { + + // For each zone passed to this worker + for z := range zones { + + // Create the list of instance resources to be retrieved for this zone + instanceResources := []*gcp.ComputeInstanceResource{} + res, err := c.computeClient.Instances.List(projectID, z).Do() + if err != nil { + glog.Fatalf("Error retrieving project %v's instances in zone %v: %v", projectID, z, err) + } + + // Create the resource + for _, i := range res.Items { + instanceResources = append(instanceResources, gcp.NewComputeInstanceResource(i)) + } + + // Pass the list of zones across the channel + results <- instanceResources + } + } + + // For each project, collect information + for _, p := range projects { + + // Only save the project ID + projectID := p.ProjectId + + // Check that the compute API is enabled for the project. If not, then skip auditing compute resources for the project entirely + if !c.isServiceEnabled(projectID, "compute.googleapis.com") { + continue + } + + // Get the compute API's version of the project + project, err := c.computeClient.Projects.Get(projectID).Do() + if err != nil { + glog.Fatalf("Error retrieving project %v's metadata: %v", projectID, err) + } + + // Store the project resource + c.computeprojects = append(c.computeprojects, gcp.NewComputeProjectResource(project)) + + // Store the project's compute metadata resource + c.computeMetadatas[projectID] = gcp.NewComputeProjectMetadataResource(project.CommonInstanceMetadata) + + instances := []*gcp.ComputeInstanceResource{} + jobs := make(chan string, len(zoneNames)) + results := make(chan []*gcp.ComputeInstanceResource, len(zoneNames)) + + // Create the zone worker pool + for w := 0; w < len(zoneNames); w++ { + go zoneWorker(projectID, w, jobs, results) + } + + // Feed the zone names + for _, z := range zoneNames { + jobs <- z + } + close(jobs) + + // Retrieve the full instances list + for i := 0; i < len(zoneNames); i++ { + instances = append(instances, <-results...) + } + + // Store the project instance's resources + c.instances[projectID] = instances + + } + + return nil +} + +// GenerateComputeMetadataReports signals the client to process ComputeMetadataResource's for reports. +// If there are no metadata keys configured in the configuration, no reports will be created. +func (c *Client) GenerateComputeMetadataReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_metadata" + + // For each project compute metadata, generate one report + for _, p := range c.computeprojects { + projectID := p.Name() + projectMetadata := c.computeMetadatas[projectID] + r := report.NewReport(typ, fmt.Sprintf("Project %v Common Instance Metadata", projectID)) + + // Always connect the data for the report with the source data + if r.Data, err = projectMetadata.Marshal(); err != nil { + glog.Fatalf("Failed to marshal project metadata: %v", err) + } + + blockSSHKeys := report.NewCISControl( + "4.2", + "Project metadata should include 'block-project-ssh-keys' and be set to 'true'", + ) + res, err := projectMetadata.KeyValueEquals("block-project-ssh-keys", "true") + if err != nil { + blockSSHKeys.Error = err.Error() + } else { + if res { + blockSSHKeys.Passed() + } else { + glog.Fatalf("Could not determine the state of project %v's metadata, aborting...", projectID) + } + } + + osLogin := report.NewCISControl( + "4.3", + "Project metadata should include the key 'enable-oslogin' with value set to 'true'", + ) + res, err = projectMetadata.KeyValueEquals("enable-oslogin", "true") + if err != nil { + osLogin.Error = err.Error() + } else { + if res { + osLogin.Passed() + } else { + glog.Fatalf("Could not determine the state of project %v's metadata, aborting...", projectID) + } + } + + // Dynamic serial port access should be denied + // serial-port-enable is a special case, where absence of the key is equivalent to disabling serial port access + serialPortAccess := report.NewCISControl( + "4.4", + "Project metadata should include the key 'serial-port-enable' with value set to '0'", + ) + if projectMetadata.KeyAbsent("serial-port-enable") { + serialPortAccess.Passed() + } else { + res, err = projectMetadata.KeyValueEquals("serial-port-enable", "0") + if err != nil { + serialPortAccess.Error = err.Error() + } else { + if res { + serialPortAccess.Passed() + } else { + glog.Fatalf("Could not determine the state of project %v's metadata, aborting...", projectID) + } + } + } + + legacyMetadata := report.NewControl( + "Ensure legacy metadata endpoints are not enabled for VM Instance", + "Project metadata should include the key 'disable-legacy-endpoints' with value set to 'true'", + ) + + res, err = projectMetadata.KeyValueEquals("disable-legacy-endpoints", "true") + if err != nil { + legacyMetadata.Error = err.Error() + } else { + if res { + legacyMetadata.Passed() + } else { + glog.Fatalf("Could not determine the state of project %v's metadata, aborting...", projectID) + } + } + + // Append the control to this resource's report + r.AddControls(blockSSHKeys, osLogin, serialPortAccess, legacyMetadata) + + // Append the resource's report to our final list + reports = append(reports, r) + c.incrementMetrics(typ, projectID, r.Status(), projectID) + } + + return +} + +// GenerateComputeInstanceReports signals the client to process ComputeInstanceResource's for reports. +// If there are keys configured for instances in the configuration, no reports will be created. +func (c *Client) GenerateComputeInstanceReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_instance" + + for _, p := range c.computeprojects { + + projectID := p.Name() + instanceResources := c.instances[projectID] + metadata := c.computeMetadatas[projectID] + + for _, i := range instanceResources { + r := report.NewReport(typ, fmt.Sprintf("Project %v Compute Instance %v", projectID, i.Name())) + if r.Data, err = i.Marshal(); err != nil { + glog.Fatalf("Failed to marshal compute instance: %v", err) + } + + // Make sure the number of Network Interfaces matches what is expected + numNicsControl := report.NewControl( + fmt.Sprintf("numNetworkInterfaces=%v", *flagComputeInstanceNumInterfaces), + fmt.Sprintf("Compute Instance should have a number of network interfaces equal to %v", *flagComputeInstanceNumInterfaces), + ) + + _, err := i.HasNumNetworkInterfaces(*flagComputeInstanceNumInterfaces) + if err != nil { + numNicsControl.Error = err.Error() + } else { + numNicsControl.Passed() + } + + // Measure whether a NAT ip address is expected + natIPControl := report.NewControl( + fmt.Sprintf("hasNatIP=%v", *flagComputeInstanceAllowNat), + fmt.Sprintf("Compute Instance should have a NAT ip configured: %v", *flagComputeInstanceAllowNat), + ) + if i.HasNatIP() { + if *flagComputeInstanceAllowNat { + // External IP exists, and we want it to exist + natIPControl.Passed() + } else { + // External IP exists, but we don't want it to exist + natIPControl.Error = "Compute Instance has NAT IP address, but should not" + } + } else { + // External IP does not exist, and we don't want it to exist + if !*flagComputeInstanceAllowNat { + natIPControl.Passed() + } else { + // It doesn't exist but we wanted it to exist + natIPControl.Error = "Compute Instance does not have a NAT IP address, but it should" + } + } + + // Default compute service account should not be used to launch instances + defaultSA := report.NewCISControl( + "4.1", + "Compute Instance should not use the project default compute service account", + ) + + if !i.UsesDefaultServiceAccount() { + defaultSA.Passed() + } else { + defaultSA.Error = "Compute instance uses a default compute service account" + } + + wrapMetadata := func(meta *gcp.ComputeProjectMetadataResource, i *gcp.ComputeInstanceResource, k string, v string) (bool, error) { + result, err := meta.KeyValueEquals(k, v) + if err != nil { + result, err = i.KeyValueEquals(k, v) + } + return result, err + } + + // Project-wide SSH keys should not be used to access instances + blockSSHKeys := report.NewCISControl( + "4.2", + "Compute Instance metadata should include 'block-project-ssh-keys' and be set to 'true'", + ) + res, err := wrapMetadata(metadata, i, "block-project-ssh-keys", "true") + if err != nil { + blockSSHKeys.Error = err.Error() + } else { + if res { + blockSSHKeys.Passed() + } else { + glog.Fatalf("Could not determine the state of instance %v's metadata, aborting...", projectID) + } + } + + // Ensure os-login is enabled + osLogin := report.NewCISControl( + "4.3", + "Compute Instance metadata should include the key 'enable-oslogin' with value set to 'true'", + ) + res, err = wrapMetadata(metadata, i, "enable-oslogin", "true") + if err != nil { + osLogin.Error = err.Error() + } else { + if res { + osLogin.Passed() + } else { + glog.Fatalf("Could not determine the state of instance %v's metadata, aborting...", projectID) + } + } + + // Dynamic serial port access should be denied + // serial-port-enable is a special case, where absence of the key is equivalent to disabling serial port access + serialPortAccess := report.NewCISControl( + "4.4", + "Compute Instance metadata should include the key 'serial-port-enable' with value set to '0'", + ) + if metadata.KeyAbsent("serial-port-enable") && i.KeyAbsent("serial-port-enable") { + serialPortAccess.Passed() + } else { + res, err = wrapMetadata(metadata, i, "serial-port-enable", "0") + if err != nil { + serialPortAccess.Error = err.Error() + } else { + if res { + serialPortAccess.Passed() + } else { + glog.Fatalf("Could not determine the state of instance %v's metadata, aborting...", projectID) + } + } + } + + // IP forwarding should not be enabled + ipForwarding := report.NewCISControl( + "4.5", + "Compute Instance should not allow ip forwarding of packets", + ) + if !i.HasIPForwardingEnabled() { + ipForwarding.Passed() + } else { + if *flagComputeInstanceAllowIPForwarding { + ipForwarding.Passed() + } else { + ipForwarding.Error = "Compute Instance allows IP Forwarding" + } + } + + // Disks should be using Customer Supplied Encryption Keys + csekDisk := report.NewCISControl( + "4.6", + "Compute Instance should be encrypted with a CSEK", + ) + if err := i.UsesCustomerSuppliedEncryptionKeys(); err != nil { + csekDisk.Passed() + } else { + csekDisk.Error = "Compute Instance does not have CSEK encryption on disk" + } + + r.AddControls( + numNicsControl, + natIPControl, + defaultSA, + blockSSHKeys, + osLogin, + serialPortAccess, + ipForwarding, + ) + + // Add the instance resource report to the final list of reports + reports = append(reports, r) + totalResourcesCounter.Inc() + c.incrementMetrics(typ, i.Name(), r.Status(), projectID) + } + } + + return +} diff --git a/pkg/client/compute_test.go b/pkg/client/compute_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/compute_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/container.go b/pkg/client/container.go new file mode 100644 index 0000000..219de60 --- /dev/null +++ b/pkg/client/container.go @@ -0,0 +1,315 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" +) + +type projectParam struct { + projectID string +} + +func (p projectParam) Get() (key, value string) { + return "projectId", p.projectID +} + +// GetContainerResources launches the process retrieving container cluster and nodepool resources +func (c *Client) GetContainerResources() error { + + defer utils.Elapsed("GetContainerResources")() + + clustersService := c.containerClient.Projects.Locations.Clusters + + // Create a short-lived goroutine for retrieving a project's container clusters + worker := func(projectIDs <-chan string, results chan<- containerCallResult) { + + id := <-projectIDs + res := containerCallResult{ProjectID: id, Clusters: []*gcp.ContainerClusterResource{}} + + // Check that the container API is enabled. If not, don't audit container resources in the project + if !c.isServiceEnabled(id, "container.googleapis.com") { + results <- res + return + } + + // Perform the query + location := fmt.Sprintf("projects/%v/locations/-", id) + clusters, err := clustersService.List(location).Do() + if err != nil { + glog.Fatalf("Error retrieving container clusters in project %v: %v", id, err) + } + + for _, cluster := range clusters.Clusters { + res.Clusters = append(res.Clusters, gcp.NewContainerClusterResource(cluster)) + } + + results <- res + } + + // Setup worker pool + projectIDs := make(chan string, len(c.resourceprojects)) + results := make(chan containerCallResult, len(c.resourceprojects)) + numWorkers := len(c.resourceprojects) + for w := 0; w < numWorkers; w++ { + go worker(projectIDs, results) + } + + // Feed the workers and collect the cluster info + for _, p := range c.resourceprojects { + projectIDs <- p.ProjectId + } + + // Collect the info + for i := 0; i < numWorkers; i++ { + res := <-results + c.clusters[res.ProjectID] = res.Clusters + } + + return nil +} + +type containerCallResult struct { + ProjectID string + Clusters []*gcp.ContainerClusterResource +} + +// GenerateContainerClusterReports signals the client to process ContainerClusterResource's for reports. +func (c *Client) GenerateContainerClusterReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "container_cluster" + + for _, p := range c.computeprojects { + projectID := p.Name() + for _, cluster := range c.clusters[projectID] { + r := report.NewReport( + typ, + fmt.Sprintf("Project %v Container Cluster %v", projectID, cluster.Name()), + ) + if r.Data, err = cluster.Marshal(); err != nil { + glog.Fatalf("Failed to marshal container cluster: %v", err) + } + + // Clusters should have stackdriver logging enabled + sdLogging := report.NewCISControl( + "7.1", + fmt.Sprintf("Cluster %v should have Stackdriver logging enabled", cluster.Name()), + ) + if !cluster.IsStackdriverLoggingEnabled() { + sdLogging.Error = "Stackdriver logging is not enabled" + } else { + sdLogging.Passed() + } + + // Clusters should have stackdriver monitoring enabled + sdMonitoring := report.NewCISControl( + "7.2", + fmt.Sprintf("Cluster %v should have Stackdriver monitoring enabled", cluster.Name()), + ) + if !cluster.IsStackdriverMonitoringEnabled() { + sdMonitoring.Error = "Stackdriver monitoring is not enabled" + } else { + sdMonitoring.Passed() + } + // Clusters should not enable Attribute-Based Access Control (ABAC) + abac := report.NewCISControl( + "7.3", + fmt.Sprintf("Cluster %v should have Legacy ABAC disabled", cluster.Name()), + ) + if !cluster.IsAbacDisabled() { + abac.Error = "Cluster has Legacy ABAC enabled when it should not" + } else { + abac.Passed() + } + + // Clusters should use Master authorized networks + masterAuthNetworks := report.NewCISControl( + "7.4", + fmt.Sprintf("Cluster %v should have Master authorized networks enabled", cluster.Name()), + ) + if !cluster.IsMasterAuthorizedNetworksEnabled() { + masterAuthNetworks.Error = "Cluster does not have Master Authorized Networks enabled" + } else { + masterAuthNetworks.Passed() + } + + // Clusters should not enable Kubernetes Dashboard + dashboard := report.NewCISControl( + "7.6", + fmt.Sprintf("Cluster %v should have Kubernetes Dashboard disabled", cluster.Name()), + ) + if !cluster.IsDashboardAddonDisabled() { + dashboard.Error = "Cluster has Kubernetes Dashboard add-on enabled when it should not" + } else { + dashboard.Passed() + } + + // Clusters should not allow authentication with username/password + masterAuthPassword := report.NewCISControl( + "7.10", + fmt.Sprintf("Cluster %v should not have a password configured", cluster.Name()), + ) + if !cluster.IsMasterAuthPasswordDisabled() { + masterAuthPassword.Error = "Cluster has a password configured to allow basic auth when it should not" + } else { + masterAuthPassword.Passed() + } + + // Clusters should enable network policies (pod-to-pod policy) + networkPolicy := report.NewCISControl( + "7.11", + fmt.Sprintf("Cluster %v should have Network Policy addon enabled", cluster.Name()), + ) + if !cluster.IsNetworkPolicyAddonEnabled() { + networkPolicy.Error = "Cluster does not have Network Policy addon enabled when it should" + } else { + networkPolicy.Passed() + } + + // Clusters should not allow authentication with client certificates + clientCert := report.NewCISControl( + "7.12", + fmt.Sprintf("Cluster %v should not issue client certificates", cluster.Name()), + ) + if !cluster.IsClientCertificateDisabled() { + clientCert.Error = "Cluster has ABAC enabled when it should not" + } else { + clientCert.Passed() + } + + // Clusters should be launched as VPC-native and use Pod Alias IP ranges + aliasIps := report.NewCISControl( + "7.13", + fmt.Sprintf("Cluster %v should use VPC-native alias IP ranges", cluster.Name()), + ) + if !cluster.IsAliasIPEnabled() { + aliasIps.Error = "Cluster is not using VPC-native alias IP ranges" + } else { + aliasIps.Passed() + } + + // Cluster master should not be accessible over public IP + privateMaster := report.NewCISControl( + "7.15", + fmt.Sprintf("Cluster %v master should be private and not accessible over public IP", cluster.Name()), + ) + if !cluster.IsMasterPrivate() { + privateMaster.Error = "Cluster master is not private and is routeable on public internet" + } else { + privateMaster.Passed() + } + + // Cluster nodes should not be accessible over public IP + privateNodes := report.NewCISControl( + "7.15", + fmt.Sprintf("Cluster %v nodes should be private and not accessible over public IPs", cluster.Name()), + ) + if !cluster.IsNodesPrivate() { + privateNodes.Error = "Cluster nodes are not private and are routable on the public internet" + } else { + privateNodes.Passed() + } + + // Cluster should not be launched using the default compute service account + defaultSA := report.NewCISControl( + "7.17", + fmt.Sprintf("Cluster %v should not be using the default compute service account", cluster.Name()), + ) + if cluster.IsUsingDefaultServiceAccount() { + defaultSA.Error = "Cluster is using the default compute service account" + } else { + defaultSA.Passed() + } + + // Cluster should be using minimal OAuth scopes + oauthScopes := report.NewCISControl( + "7.18", + fmt.Sprintf("Cluster %v should be launched with minimal OAuth scopes", cluster.Name()), + ) + if _, err := cluster.IsUsingMinimalOAuthScopes(); err != nil { + oauthScopes.Error = err.Error() + } else { + oauthScopes.Passed() + } + + r.AddControls(sdLogging, sdMonitoring, abac, masterAuthNetworks, dashboard, masterAuthPassword, networkPolicy, clientCert, aliasIps, privateMaster, privateNodes, defaultSA, oauthScopes) + reports = append(reports, r) + c.incrementMetrics(typ, cluster.Name(), r.Status(), projectID) + } + } + + return +} + +// GenerateContainerNodePoolReports signals the client to process ContainerNodePoolResource's for reports. +func (c *Client) GenerateContainerNodePoolReports() (reports []report.Report, err error) { + reports = []report.Report{} + typ := "container_nodepool" + + for _, p := range c.computeprojects { + projectID := p.Name() + for _, nodepool := range c.nodepools[projectID] { + r := report.NewReport( + typ, + fmt.Sprintf("Project %v Container Node Pool %v", projectID, nodepool.Name()), + ) + if r.Data, err = nodepool.Marshal(); err != nil { + glog.Fatalf("Failed to marshal container node pool: %v", err) + } + + // Nodepools should not allow use of legacy metadata APIs + legacyAPI := report.NewControl( + "disableLegacyMetadataAPI", + fmt.Sprintf("Node pool %v should have legacy metadata API disabled", nodepool.Name()), + ) + if _, err := nodepool.IsLegacyMetadataAPIDisabled(); err != nil { + legacyAPI.Error = err.Error() + } else { + legacyAPI.Passed() + } + + // Node pools should be configured for automatic repairs + repair := report.NewCISControl( + "7.7", + fmt.Sprintf("Node pool %v should have automatic repairs enabled", nodepool.Name()), + ) + if !nodepool.IsAutoRepairEnabled() { + repair.Error = "Automatic node repair is not enabled" + } else { + repair.Passed() + } + + // Node pools should be configured for automatic upgrades + upgrade := report.NewCISControl( + "7.8", + fmt.Sprintf("Node pool %v should have automatic upgrades enabled", nodepool.Name()), + ) + if !nodepool.IsAutoUpgradeEnabled() { + upgrade.Error = "Automatic node upgrade is not enabled" + } else { + upgrade.Passed() + } + + // Node pools should be using COS (Google Container OS) + cos := report.NewCISControl( + "7.9", + fmt.Sprintf("Node pool %v should be using COS", nodepool.Name()), + ) + if _, err := nodepool.CheckDistributionTypeIs("COS"); err != nil { + cos.Error = err.Error() + } else { + cos.Passed() + } + + r.AddControls(legacyAPI, repair, upgrade, cos) + reports = append(reports, r) + c.incrementMetrics(typ, nodepool.Name(), r.Status(), projectID) + } + } + + return +} diff --git a/pkg/client/container_test.go b/pkg/client/container_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/container_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/flags.go b/pkg/client/flags.go new file mode 100644 index 0000000..fa14580 --- /dev/null +++ b/pkg/client/flags.go @@ -0,0 +1,21 @@ +package client + +import ( + "flag" + + "github.com/Unity-Technologies/nemesis/pkg/utils" +) + +var ( + // Metrics + flagMetricsEnabled = flag.Bool("metrics.enabled", utils.GetEnvBool("NEMESIS_METRICS_ENABLED"), "Enable Prometheus metrics") + flagMetricsGateway = flag.String("metrics.gateway", utils.GetEnv("NEMESIS_METRICS_GATEWAY", "127.0.0.1:9091"), "Prometheus metrics Push Gateway") + + // Projects + flagProjectFilter = flag.String("project.filter", utils.GetEnv("NEMESIS_PROJECT_FILTER", ""), "REQUIRED - the project filter to perform audits on.") + + // Compute + flagComputeInstanceNumInterfaces = flag.Int("compute.instance.num-interfaces", utils.GetEnvInt("NEMESIS_COMPUTE_NUM_NICS", 1), "The number of network interfaces (NIC) that an instance should have") + flagComputeInstanceAllowNat = flag.Bool("compute.instance.allow-nat", utils.GetEnvBool("NEMESIS_COMPUTE_ALLOW_NAT"), "Indicate whether instances should be allowed to have external (NAT) IP addresses") + flagComputeInstanceAllowIPForwarding = flag.Bool("compute.instance.allow-ip-forwarding", utils.GetEnvBool("NEMESIS_COMPUTE_ALLOW_IP_FORWARDING"), "Indicate whether instances should be allowed to perform IP forwarding") +) diff --git a/pkg/client/iam.go b/pkg/client/iam.go new file mode 100644 index 0000000..76b62dd --- /dev/null +++ b/pkg/client/iam.go @@ -0,0 +1,214 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" + "google.golang.org/api/cloudresourcemanager/v1" +) + +// GetIamResources gathers the list of IAM resources for the projects +func (c *Client) GetIamResources() error { + + defer utils.Elapsed("GetIamResources")() + + worker := func(projectIDs <-chan string, results chan<- iamCallResult) { + + id := <-projectIDs + projectID := fmt.Sprintf("projects/%v", id) + res := iamCallResult{ProjectID: id, Policy: nil, ServiceAccounts: []*gcp.IamServiceAccountResource{}} + + req := cloudresourcemanager.GetIamPolicyRequest{} + policy, err := c.cloudResourceClient.Projects.GetIamPolicy(id, &req).Do() + if err != nil { + glog.Fatalf("Failed to retrieve IAM policy for project %v: %v", id, err) + } + + res.Policy = gcp.NewIamPolicyResource(policy) + + saList, err := c.iamClient.Projects.ServiceAccounts.List(projectID).Do() + if err != nil { + glog.Fatalf("Failed to retrieve service accounts from project %v: %v", id, err) + } + + for _, a := range saList.Accounts { + + acct := gcp.NewIamServiceAccountResource(a) + saKeySearch := fmt.Sprintf("%v/serviceAccounts/%v", projectID, a.UniqueId) + keys, err := c.iamClient.Projects.ServiceAccounts.Keys.List(saKeySearch).KeyTypes("USER_MANAGED").Do() + if err != nil { + glog.Fatalf("Failed to retrieve service account keys from project %v: %v", id, err) + } + for _, k := range keys.Keys { + acct.Keys = append(acct.Keys, k) + } + + res.ServiceAccounts = append(res.ServiceAccounts, acct) + } + + results <- res + } + + // Setup worker pool + projectIDs := make(chan string, len(c.resourceprojects)) + results := make(chan iamCallResult, len(c.resourceprojects)) + numWorkers := len(c.resourceprojects) + for w := 0; w < numWorkers; w++ { + go worker(projectIDs, results) + } + + // Feed the workers and collect the cluster info + for _, p := range c.resourceprojects { + projectIDs <- p.ProjectId + } + + // Collect the info + for i := 0; i < numWorkers; i++ { + res := <-results + c.policies[res.ProjectID] = res.Policy + c.serviceaccounts[res.ProjectID] = res.ServiceAccounts + } + + return nil +} + +type iamCallResult struct { + ProjectID string + Policy *gcp.IamPolicyResource + ServiceAccounts []*gcp.IamServiceAccountResource +} + +// GenerateIAMPolicyReports signals the client to process IamPolicyResource's for reports. +func (c *Client) GenerateIAMPolicyReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "iam_policy" + + for _, p := range c.computeprojects { + projectID := p.Name() + policy := c.policies[projectID] + serviceAccounts := c.serviceaccounts[projectID] + + r := report.NewReport( + typ, + fmt.Sprintf("Project %v IAM Policy", projectID), + ) + r.Data, err = policy.Marshal() + if err != nil { + glog.Fatalf("Failed to marshal IAM policy: %v", err) + } + + // Corporate login credentials should be used + corpCreds := report.NewCISControl( + "1.1", + fmt.Sprintf("Project %v should only allow corporate login credentials", p.Name()), + ) + if err := policy.PolicyViolatesUserDomainWhitelist(); err != nil { + corpCreds.Error = err.Error() + } else { + corpCreds.Passed() + } + r.AddControls(corpCreds) + + for _, sa := range serviceAccounts { + + // Service account keys should be GCP-managed + saManagedKeys := report.NewCISControl( + "1.3", + fmt.Sprintf("%v should not have user-managed keys", sa.Email()), + ) + if sa.HasUserManagedKeys() { + saManagedKeys.Error = "Service account has user-managed keys" + } else { + saManagedKeys.Passed() + } + r.AddControls(saManagedKeys) + + // Service accounts should not have admin privileges + saAdminRole := report.NewCISControl( + "1.4", + fmt.Sprintf("%v should not have admin roles", sa.Email()), + ) + if err := policy.MemberHasAdminRole(fmt.Sprintf("serviceAccount:%v", sa.Email())); err != nil { + saAdminRole.Error = err.Error() + } else { + saAdminRole.Passed() + } + r.AddControls(saAdminRole) + + } + + // IAM Users should not be able to impersonate service accounts at the project level + saServiceAccountUserRole := report.NewCISControl( + "1.5", + fmt.Sprintf("Project %v should not allow project-wide use of Service Account User role", p.Name()), + ) + if err := policy.PolicyAllowsIAMUserServiceAccountUserRole(); err != nil { + saServiceAccountUserRole.Error = err.Error() + } else { + saServiceAccountUserRole.Passed() + } + r.AddControls(saServiceAccountUserRole) + + // Service account keys should be rotated on a regular interval + for _, sa := range serviceAccounts { + saKeyExpired := report.NewCISControl( + "1.6", + fmt.Sprintf("%v should not have expired keys", sa.Email()), + ) + if err := sa.HasKeysNeedingRotation(); err != nil { + saKeyExpired.Error = err.Error() + } else { + saKeyExpired.Passed() + } + r.AddControls(saKeyExpired) + } + + // Users should not be allowed to administrate and impersonate service accounts + saSeperateDuties := report.NewCISControl( + "1.7", + fmt.Sprintf("Project %v should have separation of duties with respect to service account usage", p.Name()), + ) + if err := policy.PolicyViolatesServiceAccountSeparationoOfDuties(); err != nil { + saSeperateDuties.Error = err.Error() + } else { + saSeperateDuties.Passed() + } + r.AddControls(saSeperateDuties) + + // Users should not be allowed to administrate and utilize KMS functionality + kmsSeperateDuties := report.NewCISControl( + "1.9", + fmt.Sprintf("Project %v should have separation of duties with respect to KMS usage", p.Name()), + ) + if err := policy.PolicyViolatesKMSSeparationoOfDuties(); err != nil { + kmsSeperateDuties.Error = err.Error() + } else { + kmsSeperateDuties.Passed() + } + r.AddControls(kmsSeperateDuties) + + // Project IAM Policies should define audit configurations + auditConfig := report.NewCISControl( + "2.1", + fmt.Sprintf("Project %v should proper audit logging configurations", p.Name()), + ) + if err := policy.PolicyConfiguresAuditLogging(); err != nil { + auditConfig.Error = err.Error() + } else { + if err := policy.PolicyDoesNotHaveAuditLogExceptions(); err != nil { + auditConfig.Error = err.Error() + } else { + auditConfig.Passed() + } + } + r.AddControls(auditConfig) + + reports = append(reports, r) + } + + return +} diff --git a/pkg/client/iam_test.go b/pkg/client/iam_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/iam_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/logging.go b/pkg/client/logging.go new file mode 100644 index 0000000..377013b --- /dev/null +++ b/pkg/client/logging.go @@ -0,0 +1,237 @@ +package client + +import ( + "context" + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + + "google.golang.org/api/iterator" + + "github.com/Unity-Technologies/nemesis/pkg/utils" + loggingpb "google.golang.org/genproto/googleapis/logging/v2" +) + +// GetLoggingResources returns the logging config and log-based metric configurations +func (c *Client) GetLoggingResources() error { + + defer utils.Elapsed("GetLoggingResources")() + + worker := func(projectIDs <-chan string, results chan<- loggingClientResult) { + + id := <-projectIDs + parent := fmt.Sprintf("projects/%s", id) + + ctx := context.Background() + res := loggingClientResult{ProjectID: id, LogSinks: []*gcp.LoggingSinkResource{}} + + // Grab the project's logging sinks + req1 := loggingpb.ListSinksRequest{ + Parent: parent, + } + it1 := c.logConfigClient.ListSinks(ctx, &req1) + for { + s, done := it1.Next() + if done == iterator.Done { + break + } + + res.LogSinks = append(res.LogSinks, gcp.NewLoggingSinkResource(s)) + } + + // Grab the project's log-based metrics + req2 := loggingpb.ListLogMetricsRequest{ + Parent: parent, + } + it2 := c.logMetricClient.ListLogMetrics(ctx, &req2) + for { + m, done := it2.Next() + if done == iterator.Done { + break + } + + res.LogMetrics = append(res.LogMetrics, gcp.NewLoggingMetricResource(m)) + } + + results <- res + } + + projectIDs := make(chan string, len(c.resourceprojects)) + results := make(chan loggingClientResult, len(c.resourceprojects)) + numWorkers := len(c.resourceprojects) + for w := 0; w < numWorkers; w++ { + go worker(projectIDs, results) + } + + for _, p := range c.resourceprojects { + projectIDs <- p.ProjectId + } + + for i := 0; i < numWorkers; i++ { + res := <-results + c.logSinks[res.ProjectID] = res.LogSinks + c.logMetrics[res.ProjectID] = res.LogMetrics + } + + return nil +} + +type loggingClientResult struct { + ProjectID string + LogSinks []*gcp.LoggingSinkResource + LogMetrics []*gcp.LoggingMetricResource +} + +// GenerateLoggingReports signals the client to process LoggingResources for reports. +// TODO - implement CIS 2.3 +func (c *Client) GenerateLoggingReports() (reports []report.Report, err error) { + + reports = []report.Report{} + + for _, p := range c.computeprojects { + + r := report.NewReport( + "logging_configuration", + fmt.Sprintf("Project %s Logging Configuration", p.Name()), + ) + + // At least one sink in a project should ship all logs _somewhere_ + exportLogs := report.NewCISControl( + "2.2", + fmt.Sprintf("Project %s should have at least one export configured with no filters", p.Name()), + ) + isExported := false + for _, s := range c.logSinks[p.Name()] { + isExported = s.ShipsAllLogs() + if isExported { + break + } + } + if !isExported { + exportLogs.Error = fmt.Sprintf("There is no logging sink that exports all logs for project %s", p.Name()) + } else { + exportLogs.Passed() + } + + // Helper function to determine if a list of log-based metrics contains a specific filter + metricExists := func(metrics []*gcp.LoggingMetricResource, filter string) bool { + for _, m := range metrics { + if m.FilterMatches(filter) { + return true + } + } + return false + } + + // Monitor Project Ownership Changes + projectOwnerChanges := report.NewCISControl( + "2.4", + fmt.Sprintf("Project %s should monitor ownership changes", p.Name()), + ) + + // Monitor Project Audit Configuration Changes + auditConfigChanges := report.NewCISControl( + "2.5", + fmt.Sprintf("Project %s should monitor audit log configuration changes", p.Name()), + ) + + // Monitor Project Custom Role Changes + customRoleChanges := report.NewCISControl( + "2.6", + fmt.Sprintf("Project %s should monitor custom IAM role changes", p.Name()), + ) + + // Monitor VPC Firewall Changes + vpcFirewallChanges := report.NewCISControl( + "2.7", + fmt.Sprintf("Project %s should monitor VPC firewall changes", p.Name()), + ) + + // Monitor VPC Route Changes + vpcRouteChanges := report.NewCISControl( + "2.8", + fmt.Sprintf("Project %s should monitor VPC route changes", p.Name()), + ) + + // Monitor General Changes to VPC Configuration + vpcNetworkChanges := report.NewCISControl( + "2.9", + fmt.Sprintf("Project %s should monitor VPC network changes", p.Name()), + ) + + // Monitor GCS IAM Policy Changes + gcsIamChanges := report.NewCISControl( + "2.10", + fmt.Sprintf("Project %s should monitor GCS IAM changes", p.Name()), + ) + + // Monitor SQL Configuration Changes + sqlConfigChanges := report.NewCISControl( + "2.11", + fmt.Sprintf("Project %s should monitor SQL config changes", p.Name()), + ) + + metricControls := []struct { + Control *report.Control + Filter string + }{ + { + Control: &projectOwnerChanges, + Filter: `(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")`, + }, + { + Control: &auditConfigChanges, + Filter: `protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*`, + }, + { + Control: &customRoleChanges, + Filter: `resource.type="iam_role" AND protoPayload.methodName = "google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole"`, + }, + { + Control: &vpcFirewallChanges, + Filter: `resource.type="gce_firewall_rule" AND jsonPayload.event_subtype="compute.firewalls.patch" OR jsonPayload.event_subtype="compute.firewalls.insert"`, + }, + { + Control: &vpcRouteChanges, + Filter: `resource.type="gce_route" AND jsonPayload.event_subtype="compute.routes.delete" OR jsonPayload.event_subtype="compute.routes.insert"`, + }, + { + Control: &vpcNetworkChanges, + Filter: `resource.type=gce_network AND jsonPayload.event_subtype="compute.networks.insert" OR jsonPayload.event_subtype="compute.networks.patch" OR jsonPayload.event_subtype="compute.networks.delete" OR jsonPayload.event_subtype="compute.networks.removePeering" OR jsonPayload.event_subtype="compute.networks.addPeering"`, + }, + { + Control: &gcsIamChanges, + Filter: `resource.type=gcs_bucket AND protoPayload.methodName="storage.setIamPermissions"`, + }, + { + Control: &sqlConfigChanges, + Filter: `protoPayload.methodName="cloudsql.instances.update"`, + }, + } + + for _, m := range metricControls { + if metricExists(c.logMetrics[p.Name()], m.Filter) { + m.Control.Passed() + } else { + m.Control.Error = fmt.Sprintf("Project %s does not have the following filter monitored: %s", p.Name(), m.Filter) + } + } + + r.AddControls( + exportLogs, + projectOwnerChanges, + auditConfigChanges, + customRoleChanges, + vpcFirewallChanges, + vpcRouteChanges, + vpcNetworkChanges, + gcsIamChanges, + sqlConfigChanges, + ) + reports = append(reports, r) + } + + return +} diff --git a/pkg/client/logging_test.go b/pkg/client/logging_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/logging_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/metrics.go b/pkg/client/metrics.go new file mode 100644 index 0000000..a9ebd30 --- /dev/null +++ b/pkg/client/metrics.go @@ -0,0 +1,82 @@ +package client + +import ( + "errors" + "fmt" + + "github.com/prometheus/client_golang/prometheus" + push "github.com/prometheus/client_golang/prometheus/push" +) + +var ( + promNamespace = "nemesis" + + // Prometheus metrics + // Total resources scanned + totalResourcesCounter = prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: promNamespace, + Name: "total_resources_scanned", + Help: "Total number of resources scanned", + }, + ) + // Report summaries, reported by type, status, and project + reportSummary = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: promNamespace, + Name: "report_summary", + Help: "Report summaries by type, status, and project", + }, + []string{"type", "name", "status", "project"}, + ) +) + +// configureMetrics is a helper function for configuring metrics. +// Since we use a push gateway, we must configure our metrics as a push model +func configureMetrics() *push.Pusher { + + // Only configure metrics collection if enabled + if *flagMetricsEnabled { + + // Create the prometheus registry. We explicitly declare a registry rather than + // depend on the default registry + registry := prometheus.NewRegistry() + + // Register the necessary metrics + registry.MustRegister(totalResourcesCounter) + registry.MustRegister(reportSummary) + + // Configure the gateway and return the pusher + pusher := push.New(*flagMetricsGateway, "nemesis_audit").Gatherer(registry) + return pusher + } + + return nil +} + +// incrementMetrics is a small helper to consolidate reporting metrics that are reported for all resources +func (c *Client) incrementMetrics(typ string, name string, status string, projectID string) { + totalResourcesCounter.Inc() + reportSummary.WithLabelValues(typ, name, status, projectID).Inc() +} + +// PushMetrics pushes the collected metrics from this client. Should only be called once. +func (c *Client) PushMetrics() error { + + // Only push metrics if we configured it + if c.pusher != nil { + + if c.metricsArePushed { + return errors.New("Metrics were already pushed, make sure client.PushMetrics is only called once") + } + + if err := c.pusher.Add(); err != nil { + return fmt.Errorf("Failed to push metrics to gateway: %v", err) + } + + // Indicate that metrics for the client have already been pushed + c.metricsArePushed = true + } + + return nil +} diff --git a/pkg/client/network.go b/pkg/client/network.go new file mode 100644 index 0000000..45ac986 --- /dev/null +++ b/pkg/client/network.go @@ -0,0 +1,323 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" + + compute "google.golang.org/api/compute/v1" +) + +// GetNetworkResources launches the process retrieving network resources +func (c *Client) GetNetworkResources() error { + + defer utils.Elapsed("GetNetworkResources")() + + regionNames, err := c.getRegionNames() + if err != nil { + glog.Fatalf("%v", err) + } + + worker := func(projectIDs <-chan string, results chan<- networkCallResult) { + id := <-projectIDs + + res := networkCallResult{ + ProjectID: id, + Networks: []*gcp.ComputeNetworkResource{}, + Subnetworks: []*gcp.ComputeSubnetworkResource{}, + Firewalls: []*gcp.ComputeFirewallRuleResource{}, + Addresses: []*gcp.ComputeAddressResource{}, + } + + // Get all networks active in the project + networks, err := c.computeClient.Networks.List(id).Do() + if err != nil { + glog.Fatalf("Error retrieving networks from project '%v': %v", id, err) + } + + for _, n := range networks.Items { + res.Networks = append(res.Networks, gcp.NewComputeNetworkResource(n)) + } + + // Get all subnetworks active in the project + for _, region := range regionNames { + var subnetworks *compute.SubnetworkList + + subnetworks, err = c.computeClient.Subnetworks.List(id, region).Do() + if err != nil { + glog.Fatalf("Error retrieving subnetworks from project '%v': %v", id, err) + } + + for _, s := range subnetworks.Items { + res.Subnetworks = append(res.Subnetworks, gcp.NewComputeSubnetworkResource(s)) + } + + for subnetworks.NextPageToken != "" { + subnetworks, err := c.computeClient.Subnetworks.List(id, region).PageToken(subnetworks.NextPageToken).Do() + if err != nil { + glog.Fatalf("Error retrieving subnetworks from project '%v': %v", id, err) + } + + for _, s := range subnetworks.Items { + res.Subnetworks = append(res.Subnetworks, gcp.NewComputeSubnetworkResource(s)) + } + } + } + + // Get all firewall rules active in audited projects, for all networks in the projects + + var firewalls *compute.FirewallList + + firewalls, err = c.computeClient.Firewalls.List(id).Do() + if err != nil { + glog.Fatalf("Error retrieving firewall rules from project '%v': %v", id, err) + } + + for _, f := range firewalls.Items { + res.Firewalls = append(res.Firewalls, gcp.NewComputeFirewallRuleResource(f)) + } + + for firewalls.NextPageToken != "" { + firewalls, err = c.computeClient.Firewalls.List(id).PageToken(firewalls.NextPageToken).Do() + if err != nil { + glog.Fatalf("Error retrieving firewall rules from project '%v': %v", id, err) + } + + for _, f := range firewalls.Items { + res.Firewalls = append(res.Firewalls, gcp.NewComputeFirewallRuleResource(f)) + } + } + + // Get aggregated IPs for the projects + aggregateAddressesList, err := c.computeClient.Addresses.AggregatedList(id).Do() + if err != nil { + glog.Fatalf("Error retrieving addresses from project '%v': %v", id, err) + } + + scopedAddresses := aggregateAddressesList.Items + for _, scope := range scopedAddresses { + for _, a := range scope.Addresses { + res.Addresses = append(res.Addresses, gcp.NewComputeAddressResource(a)) + } + } + + results <- res + } + + // Setup worker pool + projectIDs := make(chan string, len(c.resourceprojects)) + results := make(chan networkCallResult, len(c.resourceprojects)) + numWorkers := len(c.resourceprojects) + for w := 0; w < numWorkers; w++ { + go worker(projectIDs, results) + } + + // Feed the workers and collect the storage info + for _, p := range c.resourceprojects { + projectIDs <- p.ProjectId + } + + // Collect the info + for i := 0; i < numWorkers; i++ { + res := <-results + c.networks[res.ProjectID] = res.Networks + c.subnetworks[res.ProjectID] = res.Subnetworks + c.firewalls[res.ProjectID] = res.Firewalls + c.addresses[res.ProjectID] = res.Addresses + } + + return nil +} + +type networkCallResult struct { + ProjectID string + Networks []*gcp.ComputeNetworkResource + Subnetworks []*gcp.ComputeSubnetworkResource + Firewalls []*gcp.ComputeFirewallRuleResource + Addresses []*gcp.ComputeAddressResource +} + +// GenerateComputeNetworkReports signals the client to process ComputeNetworkResource's for reports. +// If there are no networks found in the configuration, no reports will be created. +func (c *Client) GenerateComputeNetworkReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_network" + + for _, p := range c.computeprojects { + projectID := p.Name() + + for _, n := range c.networks[p.Name()] { + r := report.NewReport( + typ, + fmt.Sprintf("Network %v in Project %v", n.Name(), p.Name()), + ) + r.Data, err = n.Marshal() + if err != nil { + glog.Fatalf("Failed to marshal network: %v", err) + } + + // The default network should not be used in projects + defaultNetworkControl := report.NewCISControl( + "3.1", + fmt.Sprintf("Project %v should not have a default network", p.Name()), + ) + if n.IsDefault() { + defaultNetworkControl.Error = fmt.Sprintf("Network %v is the default network", n.Name()) + } else { + defaultNetworkControl.Passed() + } + + // Legacy networks should not be used + legacyNetworkControl := report.NewCISControl( + "3.2", + fmt.Sprintf("Project %v should not have legacy networks", p.Name()), + ) + if n.IsLegacy() { + legacyNetworkControl.Error = fmt.Sprintf("Network %v is a legacy network", n.Name()) + } else { + legacyNetworkControl.Passed() + } + + r.AddControls(defaultNetworkControl, legacyNetworkControl) + + reports = append(reports, r) + c.incrementMetrics(typ, n.Name(), r.Status(), projectID) + } + } + + return +} + +// GenerateComputeSubnetworkReports signals the client to process ComputeSubnetworkResource's for reports. +// If there are no subnetworks found in the configuration, no reports will be created. +func (c *Client) GenerateComputeSubnetworkReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_subnetwork" + + for _, p := range c.computeprojects { + projectID := p.Name() + + for _, s := range c.subnetworks[p.Name()] { + r := report.NewReport( + typ, + fmt.Sprintf("Subnetwork %v in region %v for Project %v", s.Name(), s.Region(), p.Name()), + ) + r.Data, err = s.Marshal() + if err != nil { + glog.Fatalf("Failed to marshal subnetwork: %v", err) + } + + privateAccessControl := report.NewCISControl( + "3.8", + fmt.Sprintf("Subnetwork %v should have Private Google Access enabled", s.Name()), + ) + if s.IsPrivateGoogleAccessEnabled() { + privateAccessControl.Passed() + } else { + privateAccessControl.Error = fmt.Sprintf("Subnetwork %v does not have Private Google Access enabled", s.Name()) + } + + flowLogsControl := report.NewCISControl( + "3.9", + fmt.Sprintf("Subnetwork %v should have VPC flow logs enabled", s.Name()), + ) + if s.IsFlowLogsEnabled() { + flowLogsControl.Passed() + } else { + flowLogsControl.Error = fmt.Sprintf("Subnetwork %v does not have VPC flow logs enabled", s.Name()) + } + + r.AddControls(privateAccessControl, flowLogsControl) + + reports = append(reports, r) + c.incrementMetrics(typ, s.Name(), r.Status(), projectID) + } + } + + return +} + +// GenerateComputeFirewallRuleReports signals the client to process ComputeFirewallRuleResource's for reports. +// If there are no network keys configured in the configuration, no reports will be created. +func (c *Client) GenerateComputeFirewallRuleReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_firewall_rule" + + for _, p := range c.computeprojects { + projectID := p.Name() + + for _, f := range c.firewalls[p.Name()] { + r := report.NewReport( + typ, + fmt.Sprintf("Network %v Firewall Rule %v", f.Network(), f.Name()), + ) + r.Data, err = f.Marshal() + if err != nil { + glog.Fatalf("Failed to marshal firewall rule: %v", err) + } + + // SSH access from the internet should not be allowed + sshControl := report.NewCISControl( + "3.6", + "SSH should not be allowed from the internet", + ) + if (f.AllowsProtocolPort("TCP", "22") || f.AllowsProtocolPort("UDP", "22")) && f.AllowsSourceRange("0.0.0.0/0") { + sshControl.Error = fmt.Sprintf("%v allows SSH from the internet", f.Name()) + } else { + sshControl.Passed() + } + + // RDP access from the internet should not be allowed + rdpControl := report.NewCISControl( + "3.7", + "RDP should not be allowed from the internet", + ) + if (f.AllowsProtocolPort("TCP", "3389") || f.AllowsProtocolPort("UDP", "3389")) && f.AllowsSourceRange("0.0.0.0/0") { + rdpControl.Error = fmt.Sprintf("%v allows RDP froom the internet", f.Name()) + } else { + rdpControl.Passed() + } + + r.AddControls(sshControl, rdpControl) + reports = append(reports, r) + c.incrementMetrics(typ, f.Name(), r.Status(), projectID) + } + } + + return +} + +// GenerateComputeAddressReports signals the client to process ComputeAddressResource's for reports. +// If there are no network keys configured in the configuration, no reports will be created. +func (c *Client) GenerateComputeAddressReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "compute_address" + + for _, p := range c.computeprojects { + + projectID := p.Name() + for _, a := range c.addresses[projectID] { + + r := report.NewReport( + typ, + fmt.Sprintf("Compute Address %v", a.Name()), + ) + r.Data, err = a.Marshal() + if err != nil { + glog.Fatalf("Failed to marshal compute address: %v", err) + } + + reports = append(reports, r) + c.incrementMetrics(typ, a.Name(), r.Status(), projectID) + } + } + + return +} diff --git a/pkg/client/network_test.go b/pkg/client/network_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/network_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/oauth.go b/pkg/client/oauth.go new file mode 100644 index 0000000..4e7b3bd --- /dev/null +++ b/pkg/client/oauth.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + "net/http" + + "github.com/golang/glog" + "golang.org/x/oauth2/google" +) + +// getOAuthClient configures an http.client capable of authenticating to Google APIs +func getOAuthClient(ctx context.Context, scope string) *http.Client { + + // Configure client with OAuth + oauthClient, err := google.DefaultClient(ctx, scope) + if err != nil { + glog.Fatalf("Failed to create OAuth client: %v", err) + } + + return oauthClient +} diff --git a/pkg/client/oauth_test.go b/pkg/client/oauth_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/oauth_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/projects.go b/pkg/client/projects.go new file mode 100644 index 0000000..03699b2 --- /dev/null +++ b/pkg/client/projects.go @@ -0,0 +1,77 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" +) + +// GetProjects gathers the list of projects and active API resources for the project +func (c *Client) GetProjects() error { + + if *flagProjectFilter == "" { + glog.Exitf("No project filter was provided. Either specify --project.filter or set NEMESIS_PROJECT_FILTER to the appropriate regex (e.g. my-cool-projects-*)") + } + + defer utils.Elapsed("GetProjects")() + + // Get list of all projects. + // Additionally we must make sure that the project is ACTIVE. Any other state will return errors + projectFilter := fmt.Sprintf("%v AND lifecycleState=ACTIVE", *flagProjectFilter) + projects := listAllProjects(projectFilter, c.cloudResourceClient) + + // Return an error that we retrieved no projects + if len(projects) == 0 { + return fmt.Errorf("No projects found when matching against '%v'", projectFilter) + } + + // Create a short-lived goroutine for retrieving project services + servicesWorker := func(workerID int, projectIDs <-chan string, results chan<- serviceCallResult) { + id := <-projectIDs + projectID := fmt.Sprintf("projects/%v", id) + + servicesList, err := c.serviceusageClient.Services.List(projectID).Filter("state:ENABLED").Do() + if err != nil { + glog.Fatalf("Failed to retrieve list of services for project %v: %v", projectID, err) + } + + projectServices := []*gcp.ServiceAPIResource{} + for _, s := range servicesList.Services { + projectServices = append(projectServices, gcp.NewServiceAPIResource(s)) + } + + res := serviceCallResult{ProjectID: id, Services: projectServices} + + results <- res + } + + // Setup worker pool + projectIDs := make(chan string, len(projects)) + results := make(chan serviceCallResult, len(projects)) + numWorkers := len(projects) + for w := 0; w < numWorkers; w++ { + go servicesWorker(w, projectIDs, results) + } + + // Feed the workers and collect the projects for reuse + for _, p := range projects { + projectIDs <- p.ProjectId + c.resourceprojects = append(c.resourceprojects, p) + } + close(projectIDs) + + // Collect the results + for i := 0; i < len(projects); i++ { + res := <-results + c.services[res.ProjectID] = res.Services + } + + return nil +} + +type serviceCallResult struct { + ProjectID string + Services []*gcp.ServiceAPIResource +} diff --git a/pkg/client/projects_test.go b/pkg/client/projects_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/projects_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/storage.go b/pkg/client/storage.go new file mode 100644 index 0000000..29da89b --- /dev/null +++ b/pkg/client/storage.go @@ -0,0 +1,137 @@ +package client + +import ( + "fmt" + + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/resource/gcp" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" +) + +// GetStorageResources launches the process retrieving storage buckets and other storage resources +func (c *Client) GetStorageResources() error { + + defer utils.Elapsed("GetStorageResources")() + + worker := func(projectIDs <-chan string, results chan<- storageCallResult) { + + id := <-projectIDs + res := storageCallResult{ProjectID: id, Buckets: []*gcp.StorageBucketResource{}} + + // Get the project's buckets + bucketList, err := c.storageClient.Buckets.List(id).Do() + if err != nil { + glog.Fatalf("Error retrieving project %v's bucket list: %v", id, err) + } + + for _, b := range bucketList.Items { + + // Get the ACLs for the bucket, as they are not included by default in the bucket list call + acls, err := c.storageClient.BucketAccessControls.List(b.Name).Do() + if err != nil { + + continue + /* + // The call above will throw a 400 error if Bucket Policy Only is enabled + if strings.Contains( + err.Error(), + "googleapi: Error 400: Cannot get legacy ACLs for a bucket that has enabled Bucket Policy Only", + ) { + continue + } + */ + + // Otherwise, we hit a real error + //glog.Fatalf("Error retrieving bucket %v's ACLs: %v", b.Name, err) + } + + // Store the ACLs with the bucket + b.Acl = acls.Items + + // Append a new bucket resource + res.Buckets = append(res.Buckets, gcp.NewStorageBucketResource(b)) + } + + results <- res + } + + // Setup worker pool + projectIDs := make(chan string, len(c.resourceprojects)) + results := make(chan storageCallResult, len(c.resourceprojects)) + numWorkers := len(c.resourceprojects) + for w := 0; w < numWorkers; w++ { + go worker(projectIDs, results) + } + + // Feed the workers and collect the storage info + for _, p := range c.resourceprojects { + projectIDs <- p.ProjectId + } + + // Collect the info + for i := 0; i < numWorkers; i++ { + res := <-results + c.buckets[res.ProjectID] = res.Buckets + } + + return nil +} + +type storageCallResult struct { + ProjectID string + Buckets []*gcp.StorageBucketResource +} + +// GenerateStorageBucketReports signals the client to process ComputeStorageBucket's for reports. +// If there are keys configured for buckets in the configuration, no reports will be created. +func (c *Client) GenerateStorageBucketReports() (reports []report.Report, err error) { + + reports = []report.Report{} + typ := "storage_bucket" + + for _, p := range c.computeprojects { + + projectID := p.Name() + projectBuckets := c.buckets[projectID] + + for _, b := range projectBuckets { + r := report.NewReport(typ, fmt.Sprintf("Project %v Storage Bucket %v", projectID, b.Name())) + if r.Data, err = b.Marshal(); err != nil { + glog.Fatalf("Failed to marshal storage bucket: %v", err) + } + + allUsersControl := report.NewCISControl( + "5.1", + "Bucket ACL should not include entity 'allUsers'", + ) + + if !b.AllowAllUsers() { + allUsersControl.Passed() + } else { + allUsersControl.Error = "Bucket ACL includes entity 'allUsers'" + } + + // Add the `allAuthenticatedUsers` entity control if the spec says that allAuthenticatedUsers == false + allAuthenticatedUsersControl := report.NewCISControl( + "5.1", + "Bucket ACL should not include entity 'allAuthenticatedUsers'", + ) + + if !b.AllowAllAuthenticatedUsers() { + allAuthenticatedUsersControl.Passed() + } else { + allAuthenticatedUsersControl.Error = "Bucket ACL includes entity 'allAuthenticatedUsers'" + } + + r.AddControls(allUsersControl, allAuthenticatedUsersControl) + + // Add the bucket report to the final list of bucket reports + reports = append(reports, r) + c.incrementMetrics(typ, b.Name(), r.Status(), projectID) + } + + } + + return +} diff --git a/pkg/client/storage_test.go b/pkg/client/storage_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/storage_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/client/utils.go b/pkg/client/utils.go new file mode 100644 index 0000000..35d51b6 --- /dev/null +++ b/pkg/client/utils.go @@ -0,0 +1,41 @@ +package client + +import ( + "fmt" + "strings" + + "github.com/golang/glog" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" +) + +// listAllProjects returns the list of all Projects visible to the authenticated client +func listAllProjects(filter string, client *cloudresourcemanager.Service) []*cloudresourcemanager.Project { + + var projects []*cloudresourcemanager.Project + + projectListCall, err := client.Projects.List().Filter(fmt.Sprintf("name:%v", filter)).Do() + if err != nil { + glog.Fatalf("Error retreiving projects: %v", err) + } + + projects = append(projects, projectListCall.Projects...) + + for projectListCall.NextPageToken != "" { + projectListCall, err := client.Projects.List().PageToken(projectListCall.NextPageToken).Do() + if err != nil { + glog.Fatalf("Error retreiving projects: %v", err) + } + projects = append(projects, projectListCall.Projects...) + } + + return projects +} + +func (c *Client) isServiceEnabled(projectID, servicename string) bool { + for _, api := range c.services[projectID] { + if strings.Contains(api.Name(), servicename) { + return true + } + } + return false +} diff --git a/pkg/client/utils_test.go b/pkg/client/utils_test.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/pkg/client/utils_test.go @@ -0,0 +1 @@ +package client diff --git a/pkg/report/flags.go b/pkg/report/flags.go new file mode 100644 index 0000000..169f700 --- /dev/null +++ b/pkg/report/flags.go @@ -0,0 +1,11 @@ +package report + +import ( + "flag" + + "github.com/Unity-Technologies/nemesis/pkg/utils" +) + +var ( + flagReportOnlyFailures = flag.Bool("reports.only-failures", utils.GetEnvBool("NEMESIS_ONLY_FAILURES"), "Limit output of controls to only failed controls") +) diff --git a/pkg/report/pubsub.go b/pkg/report/pubsub.go new file mode 100644 index 0000000..3448531 --- /dev/null +++ b/pkg/report/pubsub.go @@ -0,0 +1,76 @@ +package report + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + + "github.com/golang/glog" + + "cloud.google.com/go/pubsub" +) + +// PubSubReporter is a wrapper around the pubsub.Client. +type PubSubReporter struct { + c *pubsub.Client + topic string +} + +// NewPubSubReporter returns a new PubSubReporter for outputting the findings of an audit +func NewPubSubReporter(project string, topic string) *PubSubReporter { + psr := new(PubSubReporter) + + ctx := context.Background() + c, err := pubsub.NewClient(ctx, project) + if err != nil { + glog.Fatalf("Failed to create PubSub client: %v", err) + } + psr.c = c + psr.topic = topic + return psr +} + +// Publish sends a list of reports to the configured PubSub topic +func (r *PubSubReporter) Publish(reports []Report) error { + + // Create a pubsub publisher workgroup + ctx := context.Background() + + var wg sync.WaitGroup + var errs uint64 + topic := r.c.Topic(r.topic) + + for i := 0; i < len(reports); i++ { + + // Marshal and send the report + data, err := json.Marshal(&reports[i]) + if err != nil { + glog.Fatalf("Failed to marshal report for pubsub: %v", err) + } + + result := topic.Publish(ctx, &pubsub.Message{ + Data: data, + }) + + wg.Add(1) + go func(i int, res *pubsub.PublishResult) { + defer wg.Done() + + _, err := res.Get(ctx) + if err != nil { + glog.Errorf("Failed to publish: %v", err) + atomic.AddUint64(&errs, 1) + } + }(i, result) + } + + wg.Wait() + + if errs > 0 { + return fmt.Errorf("%d of %d reports did not publish", errs, len(reports)) + } + + return nil +} diff --git a/pkg/report/pubsub_test.go b/pkg/report/pubsub_test.go new file mode 100644 index 0000000..80c499f --- /dev/null +++ b/pkg/report/pubsub_test.go @@ -0,0 +1 @@ +package report diff --git a/pkg/report/report.go b/pkg/report/report.go new file mode 100644 index 0000000..5561da1 --- /dev/null +++ b/pkg/report/report.go @@ -0,0 +1,89 @@ +// Package report outlines how reports are formatted and validated +package report + +import ( + "encoding/json" + + "github.com/Unity-Technologies/nemesis/pkg/cis" + "github.com/golang/glog" +) + +const ( + // Failed indicates that a resource did not match expected spec + Failed = "failed" + + // Passed indicates that a resource met the expected spec + Passed = "passed" +) + +// Control is a measurable unit of an audit +type Control struct { + Title string `json:"title"` + Desc string `json:"desc"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// NewControl returns a new Control with the given title +func NewControl(title string, desc string) Control { + return Control{ + Title: title, + Desc: desc, + Status: Failed, + } +} + +// NewCISControl returns a new Control based on the CIS controls with a description +func NewCISControl(recommendationID string, desc string) Control { + rec, ok := cis.Registry[recommendationID] + if !ok { + glog.Fatalf("Couldn't find CIS recommendation with ID '%v'", recommendationID) + } + return Control{ + Title: rec.Format(), + Desc: desc, + Status: Failed, + } +} + +// Passed changes the status of the control from `false` to `true`. +func (c *Control) Passed() { + c.Status = Passed +} + +// Report is a top-level structure for capturing information generated from an audit on a resource +type Report struct { + Type string `json:"type"` + Title string `json:"title"` + Controls []Control `json:"controls"` + Data json.RawMessage `json:"data"` +} + +// NewReport returns a new top-level report with a given title +func NewReport(typ string, title string) Report { + return Report{ + Type: typ, + Title: title, + Controls: []Control{}, + } +} + +// Status returns whether a report passed all the controls it was assigned. +func (r *Report) Status() string { + for _, c := range r.Controls { + if c.Status == Failed { + return Failed + } + } + return Passed +} + +// AddControls appends controls to the report. If we only report failures, then controls that pass are not included in the report +func (r *Report) AddControls(controls ...Control) { + for _, c := range controls { + if c.Status == Passed && *flagReportOnlyFailures { + continue + } + r.Controls = append(r.Controls, c) + } +} diff --git a/pkg/report/report_test.go b/pkg/report/report_test.go new file mode 100644 index 0000000..80c499f --- /dev/null +++ b/pkg/report/report_test.go @@ -0,0 +1 @@ +package report diff --git a/pkg/report/reporter.go b/pkg/report/reporter.go new file mode 100644 index 0000000..73986ea --- /dev/null +++ b/pkg/report/reporter.go @@ -0,0 +1,6 @@ +package report + +// Reporter is a simple interface for publishing reports +type Reporter interface { + Publish(reports []Report) error +} diff --git a/pkg/report/stdout.go b/pkg/report/stdout.go new file mode 100644 index 0000000..35ffb76 --- /dev/null +++ b/pkg/report/stdout.go @@ -0,0 +1,27 @@ +package report + +import ( + "encoding/json" + "fmt" + + "github.com/golang/glog" +) + +// StdOutReporter is a reporter that prints audit reports to stdout +type StdOutReporter struct{} + +// NewStdOutReporter returns a new StdOutReporter for outputting the findings of an audit +func NewStdOutReporter() *StdOutReporter { + r := new(StdOutReporter) + return r +} + +// Publish prints a full list of reports to stdout +func (r *StdOutReporter) Publish(reports []Report) error { + b, err := json.Marshal(&reports) + if err != nil { + glog.Fatalf("Failed to render report: %v", err) + } + fmt.Println(string(b)) + return nil +} diff --git a/pkg/report/stdout_test.go b/pkg/report/stdout_test.go new file mode 100644 index 0000000..80c499f --- /dev/null +++ b/pkg/report/stdout_test.go @@ -0,0 +1 @@ +package report diff --git a/pkg/resource/gcp/compute_address.go b/pkg/resource/gcp/compute_address.go new file mode 100644 index 0000000..664396f --- /dev/null +++ b/pkg/resource/gcp/compute_address.go @@ -0,0 +1,34 @@ +package gcp + +import ( + "encoding/json" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeAddressResource is a resource describing a Google Compute Global Address, or Public IPv4 address +type ComputeAddressResource struct { + a *compute.Address +} + +// NewComputeAddressResource returns a new ComputeAddressResource +func NewComputeAddressResource(a *compute.Address) *ComputeAddressResource { + r := new(ComputeAddressResource) + r.a = a + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeAddressResource) Marshal() ([]byte, error) { + return json.Marshal(&r.a) +} + +// Name returns the name of the firewall rule +func (r *ComputeAddressResource) Name() string { + return r.a.Name +} + +// Network returns the network the firewall rule resides within +func (r *ComputeAddressResource) Network() string { + return r.a.Network +} diff --git a/pkg/resource/gcp/compute_address_test.go b/pkg/resource/gcp/compute_address_test.go new file mode 100644 index 0000000..c5bf175 --- /dev/null +++ b/pkg/resource/gcp/compute_address_test.go @@ -0,0 +1,3 @@ +package gcp + + diff --git a/pkg/resource/gcp/compute_firewall_rule.go b/pkg/resource/gcp/compute_firewall_rule.go new file mode 100644 index 0000000..c40d2cc --- /dev/null +++ b/pkg/resource/gcp/compute_firewall_rule.go @@ -0,0 +1,58 @@ +package gcp + +import ( + "encoding/json" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeFirewallRuleResource is a resource describing a Google Compute Firewall Rule +type ComputeFirewallRuleResource struct { + f *compute.Firewall +} + +// NewComputeFirewallRuleResource returns a new ComputeFirewallRuleResource +func NewComputeFirewallRuleResource(f *compute.Firewall) *ComputeFirewallRuleResource { + r := new(ComputeFirewallRuleResource) + r.f = f + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeFirewallRuleResource) Marshal() ([]byte, error) { + return json.Marshal(&r.f) +} + +// Name returns the name of the firewall rule +func (r *ComputeFirewallRuleResource) Name() string { + return r.f.Name +} + +// Network returns the network the firewall rule resides within +func (r *ComputeFirewallRuleResource) Network() string { + return r.f.Network +} + +// AllowsSourceRange returns whether a given CIDR range is allowed by the firewall rule +func (r *ComputeFirewallRuleResource) AllowsSourceRange(sourceRange string) (result bool) { + for _, s := range r.f.SourceRanges { + if s == sourceRange { + result = true + } + } + return +} + +// AllowsProtocolPort returns whether a given protocol:port combination is allowed by this firewall rule +func (r *ComputeFirewallRuleResource) AllowsProtocolPort(protocol string, port string) (result bool) { + for _, allowRule := range r.f.Allowed { + if allowRule.IPProtocol == protocol { + for _, p := range allowRule.Ports { + if port == p { + result = true + } + } + } + } + return +} diff --git a/pkg/resource/gcp/compute_firewall_rule_test.go b/pkg/resource/gcp/compute_firewall_rule_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_firewall_rule_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/compute_instance.go b/pkg/resource/gcp/compute_instance.go new file mode 100644 index 0000000..ce0dd19 --- /dev/null +++ b/pkg/resource/gcp/compute_instance.go @@ -0,0 +1,127 @@ +package gcp + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeInstanceResource represents a Google Compute Engine instance +type ComputeInstanceResource struct { + i *compute.Instance +} + +// NewComputeInstanceResource returns a new ComputeInstanceResource +func NewComputeInstanceResource(i *compute.Instance) *ComputeInstanceResource { + r := new(ComputeInstanceResource) + r.i = i + return r +} + +// Name is the compute instance name +func (r *ComputeInstanceResource) Name() string { + return r.i.Name +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeInstanceResource) Marshal() ([]byte, error) { + return json.Marshal(&r.i) +} + +// HasNatIP returns whether the instance has an external / NAT ip. +func (r *ComputeInstanceResource) HasNatIP() bool { + return r.i.NetworkInterfaces[0].AccessConfigs != nil +} + +// HasNumNetworkInterfaces returns whether the instance has the expected number of network interfaces +func (r *ComputeInstanceResource) HasNumNetworkInterfaces(num int) (result bool, err error) { + actual := len(r.i.NetworkInterfaces) + result = actual == num + if !result { + err = fmt.Errorf("Expected %v interfaces, found %v", num, actual) + } + return +} + +// KeyValueEquals returns whether the metadata key equals a given value. +// Reports an error if they metadata key does not exist. +func (r *ComputeInstanceResource) KeyValueEquals(key string, value string) (result bool, err error) { + + // Loop over project metadata keys until we find the key. + result = false + found := false + for _, item := range r.i.Metadata.Items { + if item.Key == key { + + // If we found the key, we want to check its value. + // If it is set correctly, all is well. Otherwise, report the + found = true + if strings.ToLower(*item.Value) == strings.ToLower(value) { + result = true + } else { + err = fmt.Errorf("Instance metadata key '%v' is set to '%v'", key, *item.Value) + } + return + } + } + + // Report an error that the key did not exist + if !found { + err = fmt.Errorf("Could not find instance metadata key: %v", key) + } + return +} + +// KeyAbsent returns whether the metadata key is absent +// Reports an error if they metadata key does not exist. +func (r *ComputeInstanceResource) KeyAbsent(key string) bool { + + // Loop over project metadata keys until we find the key. + found := false + for _, item := range r.i.Metadata.Items { + if item.Key == key { + + // If we found the key, return + found = true + break + } + } + + return !found +} + +// UsesDefaultServiceAccount returns whether the service account used to launch the instance +// is a default compute service account for any project +func (r *ComputeInstanceResource) UsesDefaultServiceAccount() bool { + return strings.Contains(r.i.ServiceAccounts[0].Email, "-compute@developer.gserviceaccount.com") +} + +// HasIPForwardingEnabled returns whether an instance can forward packets for different sources +func (r *ComputeInstanceResource) HasIPForwardingEnabled() bool { + return r.i.CanIpForward +} + +// UsesCustomerSuppliedEncryptionKeys returns whether the instance's disks are encrypted with a CSEK +func (r *ComputeInstanceResource) UsesCustomerSuppliedEncryptionKeys() (err error) { + + var errBuilder strings.Builder + + for _, d := range r.i.Disks { + + if d.DiskEncryptionKey == nil { + errBuilder.WriteString(fmt.Sprintf("Disk does not use CSEK: %v", d.Source)) + } + } + + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } else { + err = nil + } + + return +} diff --git a/pkg/resource/gcp/compute_instance_test.go b/pkg/resource/gcp/compute_instance_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_instance_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/compute_networks.go b/pkg/resource/gcp/compute_networks.go new file mode 100644 index 0000000..b761ec7 --- /dev/null +++ b/pkg/resource/gcp/compute_networks.go @@ -0,0 +1,48 @@ +package gcp + +import ( + "encoding/json" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeNetworkResource represents a Google Compute Engine network +type ComputeNetworkResource struct { + n *compute.Network +} + +// NewComputeNetworkResource returns a new ComputeNetworkResource +func NewComputeNetworkResource(n *compute.Network) *ComputeNetworkResource { + r := new(ComputeNetworkResource) + r.n = n + return r +} + +// Name returns the name of the Compute network +func (r *ComputeNetworkResource) Name() string { + return r.n.Name +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeNetworkResource) Marshal() ([]byte, error) { + return json.Marshal(&r.n) +} + +// IsDefault tests whether the network's name is `default`, which usually comes with a project that +// just enabled it's Compute API +func (r *ComputeNetworkResource) IsDefault() bool { + return r.n.Name == "default" +} + +// IsLegacy tests whether the network is a legacy network +func (r *ComputeNetworkResource) IsLegacy() bool { + + // If IPv4Range is non-empty, then it is a legacy network + return r.n.IPv4Range != "" +} + +// NameEquals tests whether the network's name is equal to what is expected +func (r *ComputeNetworkResource) NameEquals(name string) (result bool, err error) { + result = r.n.Name == name + return +} diff --git a/pkg/resource/gcp/compute_networks_test.go b/pkg/resource/gcp/compute_networks_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_networks_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/compute_project.go b/pkg/resource/gcp/compute_project.go new file mode 100644 index 0000000..e37d6bf --- /dev/null +++ b/pkg/resource/gcp/compute_project.go @@ -0,0 +1,35 @@ +package gcp + +import ( + "encoding/json" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeProjectResource represents a Google Compute Engine's project information. +type ComputeProjectResource struct { + p *compute.Project +} + +// NewComputeProjectResource returns a new ComputeProjectResource +func NewComputeProjectResource(p *compute.Project) *ComputeProjectResource { + r := new(ComputeProjectResource) + r.p = p + return r +} + +// Name is the Project's Name +func (r *ComputeProjectResource) Name() string { + return r.p.Name +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeProjectResource) Marshal() ([]byte, error) { + return json.Marshal(&r.p) +} + +// IsXpnHost tests whether the project is configured as a Shared VPC (Xpn) host project +func (r *ComputeProjectResource) IsXpnHost() (result bool, err error) { + result = r.p.XpnProjectStatus == "HOST" + return +} diff --git a/pkg/resource/gcp/compute_project_metadata.go b/pkg/resource/gcp/compute_project_metadata.go new file mode 100644 index 0000000..3c52156 --- /dev/null +++ b/pkg/resource/gcp/compute_project_metadata.go @@ -0,0 +1,88 @@ +package gcp + +import ( + "encoding/json" + "fmt" + "strings" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeProjectMetadataResource is a resource for testing information about a Project's compute metadata configuration +type ComputeProjectMetadataResource struct { + m *compute.Metadata +} + +// NewComputeProjectMetadataResource returns a new ComputeProjectMetadataResource +func NewComputeProjectMetadataResource(m *compute.Metadata) *ComputeProjectMetadataResource { + r := new(ComputeProjectMetadataResource) + r.m = m + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeProjectMetadataResource) Marshal() ([]byte, error) { + return json.Marshal(&r.m) +} + +// Includes indicates whether the Metadata object contains the key specified +func (r *ComputeProjectMetadataResource) Includes(key string) (result bool, err error) { + + // Loop over all project metadata keys + result = false + for _, item := range r.m.Items { + if item.Key == key { + result = true + break + } + } + + return +} + +// KeyValueEquals returns whether the metadata key equals a given value. +// Reports an error if they metadata key does not exist. +func (r *ComputeProjectMetadataResource) KeyValueEquals(key string, value string) (result bool, err error) { + + // Loop over project metadata keys until we find the key. + result = false + found := false + for _, item := range r.m.Items { + if item.Key == key { + + // If we found the key, we want to check its value. + // If it is set correctly, all is well. Otherwise, report the + found = true + if strings.ToLower(*item.Value) == strings.ToLower(value) { + result = true + } else { + err = fmt.Errorf("Project metadata key '%v' is set to '%v'", key, *item.Value) + } + return + } + } + + // Report an error that the key did not exist + if !found { + err = fmt.Errorf("Could not find project metadata key: %v", key) + } + return +} + +// KeyAbsent returns whether the metadata key is absent +// Reports an error if they metadata key does not exist. +func (r *ComputeProjectMetadataResource) KeyAbsent(key string) bool { + + // Loop over project metadata keys until we find the key. + found := false + for _, item := range r.m.Items { + if item.Key == key { + + // If we found the key, return + found = true + break + } + } + + return !found +} diff --git a/pkg/resource/gcp/compute_project_metadata_test.go b/pkg/resource/gcp/compute_project_metadata_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_project_metadata_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/compute_project_test.go b/pkg/resource/gcp/compute_project_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_project_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/compute_subnetwork.go b/pkg/resource/gcp/compute_subnetwork.go new file mode 100644 index 0000000..231015a --- /dev/null +++ b/pkg/resource/gcp/compute_subnetwork.go @@ -0,0 +1,44 @@ +package gcp + +import ( + "encoding/json" + + compute "google.golang.org/api/compute/v1" +) + +// ComputeSubnetworkResource represents a Google Compute Engine subnetwork +type ComputeSubnetworkResource struct { + s *compute.Subnetwork +} + +// NewComputeSubnetworkResource returns a new ComputeSubnetworkResource +func NewComputeSubnetworkResource(s *compute.Subnetwork) *ComputeSubnetworkResource { + r := new(ComputeSubnetworkResource) + r.s = s + return r +} + +// Name returns the name of the Compute subnetwork +func (r *ComputeSubnetworkResource) Name() string { + return r.s.Name +} + +// Region returns the GCP region of the Compute subnetwork +func (r *ComputeSubnetworkResource) Region() string { + return r.s.Region +} + +// Marshal returns the underlying resource's JSON representation +func (r *ComputeSubnetworkResource) Marshal() ([]byte, error) { + return json.Marshal(&r.s) +} + +// IsPrivateGoogleAccessEnabled returns whether private Google network access is enabled +func (r *ComputeSubnetworkResource) IsPrivateGoogleAccessEnabled() bool { + return r.s.PrivateIpGoogleAccess +} + +// IsFlowLogsEnabled returns whether the subnet has VPC flow logs enabled +func (r *ComputeSubnetworkResource) IsFlowLogsEnabled() bool { + return r.s.EnableFlowLogs +} diff --git a/pkg/resource/gcp/compute_subnetwork_test.go b/pkg/resource/gcp/compute_subnetwork_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/compute_subnetwork_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/container_cluster.go b/pkg/resource/gcp/container_cluster.go new file mode 100644 index 0000000..6d04442 --- /dev/null +++ b/pkg/resource/gcp/container_cluster.go @@ -0,0 +1,147 @@ +package gcp + +import ( + "encoding/json" + "fmt" + + container "google.golang.org/api/container/v1" +) + +const ( + loggingService = "logging.googleapis.com" + monitoringService = "monitoring.googleapis.com" +) + +// ContainerClusterResource is a resource for testing information about a GKE Cluster's configuration +type ContainerClusterResource struct { + c *container.Cluster +} + +// NewContainerClusterResource returns a new ContainerClusterResource +func NewContainerClusterResource(c *container.Cluster) *ContainerClusterResource { + r := new(ContainerClusterResource) + r.c = c + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *ContainerClusterResource) Marshal() ([]byte, error) { + return json.Marshal(&r.c) +} + +// Name returns the name given to the container cluster +func (r *ContainerClusterResource) Name() string { + return r.c.Name +} + +// IsStackdriverLoggingEnabled indicates whether logging.googleapis.com is set as the logging service +func (r *ContainerClusterResource) IsStackdriverLoggingEnabled() bool { + return r.c.LoggingService == loggingService +} + +// IsStackdriverMonitoringEnabled indicates whether monitoring.googleapis.com is set as the monitoring service +func (r *ContainerClusterResource) IsStackdriverMonitoringEnabled() bool { + return r.c.MonitoringService == monitoringService +} + +// IsAliasIPEnabled indicates whether VPC Alias IPs are being used +func (r *ContainerClusterResource) IsAliasIPEnabled() bool { + return r.c.IpAllocationPolicy.UseIpAliases +} + +// IsPodSecurityPolicyControllerEnabled indicates whether PSP controller is enabled +// TODO - currently no way to implement this check by default +func (r *ContainerClusterResource) IsPodSecurityPolicyControllerEnabled() bool { + return false + // TODO - implement! +} + +// IsDashboardAddonDisabled returns whether the GKE cluster has Kubernetes Dashboard add-on is enabled +func (r *ContainerClusterResource) IsDashboardAddonDisabled() bool { + return r.c.AddonsConfig.KubernetesDashboard.Disabled +} + +// IsMasterAuthorizedNetworksEnabled returns whether the GKE cluster is using master authorized networks +func (r *ContainerClusterResource) IsMasterAuthorizedNetworksEnabled() bool { + return r.c.MasterAuthorizedNetworksConfig.Enabled +} + +// IsAbacDisabled returns whether the GKE cluster is using (legacy) Atributed-Based Access Control +func (r *ContainerClusterResource) IsAbacDisabled() bool { + if r.c.LegacyAbac == nil { + return true + } + return !r.c.LegacyAbac.Enabled +} + +// IsNetworkPolicyAddonEnabled returns whether the GKE cluster has Network Policy add-on enabled +func (r *ContainerClusterResource) IsNetworkPolicyAddonEnabled() bool { + return !r.c.AddonsConfig.NetworkPolicyConfig.Disabled + +} + +// IsClientCertificateDisabled checks whether client certificates are disabled +func (r *ContainerClusterResource) IsClientCertificateDisabled() bool { + if r.c.MasterAuth.ClientCertificateConfig == nil { + return true + } + return !r.c.MasterAuth.ClientCertificateConfig.IssueClientCertificate +} + +// IsMasterAuthPasswordDisabled returns whether the GKE cluster has username/password authentication enabled +func (r *ContainerClusterResource) IsMasterAuthPasswordDisabled() bool { + return r.c.MasterAuth.Password == "" +} + +// IsMasterPrivate returns whether the GKE cluster master is only accessible on private networks +func (r *ContainerClusterResource) IsMasterPrivate() bool { + if r.c.PrivateClusterConfig == nil { + return false + } + return r.c.PrivateClusterConfig.EnablePrivateEndpoint +} + +// IsNodesPrivate returns whether the GKE cluster nodes are only accessible on private networks +func (r *ContainerClusterResource) IsNodesPrivate() bool { + if r.c.PrivateClusterConfig == nil { + return false + } + return r.c.PrivateClusterConfig.EnablePrivateNodes +} + +// IsUsingDefaultServiceAccount returns whether the GKE cluster is using the default compute service account +func (r *ContainerClusterResource) IsUsingDefaultServiceAccount() bool { + return r.c.NodeConfig.ServiceAccount == "default" +} + +// IsUsingMinimalOAuthScopes returns whether the GKE cluster is using the defined minimal oauth scopes for a cluster +func (r *ContainerClusterResource) IsUsingMinimalOAuthScopes() (result bool, err error) { + + // Begin with the assumption that we are using minimal oauth scopes + extraScopes := []string{} + + // Iterate over the cluster's OAuth scopes and determine if they are at most the minimal list provided. + // If there are any scopes that are not in the whitelist, track them and report them as an error + for _, scope := range r.c.NodeConfig.OauthScopes { + + found := false + // Now check if the cluster's scope is in out oauth scopes + for _, minimalScope := range minimalOAuthScopes { + if minimalScope == scope { + found = true + break + } + } + + if !found { + extraScopes = append(extraScopes, scope) + } + } + + result = len(extraScopes) == 0 + if !result { + err = fmt.Errorf("Cluster is not using minimal scopes. The following scopes are not considered minimal: %v", extraScopes) + } + + return +} diff --git a/pkg/resource/gcp/container_cluster_test.go b/pkg/resource/gcp/container_cluster_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/container_cluster_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/container_nodepool.go b/pkg/resource/gcp/container_nodepool.go new file mode 100644 index 0000000..ef583ce --- /dev/null +++ b/pkg/resource/gcp/container_nodepool.go @@ -0,0 +1,64 @@ +package gcp + +import ( + "encoding/json" + "errors" + "fmt" + + container "google.golang.org/api/container/v1" +) + +// ContainerNodePoolResource is a resource for testing information about a GKE Node Pool's configuration +type ContainerNodePoolResource struct { + n *container.NodePool +} + +// NewContainerNodePoolResource returns a new ContainerNodePoolResource +func NewContainerNodePoolResource(n *container.NodePool) *ContainerNodePoolResource { + r := new(ContainerNodePoolResource) + r.n = n + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *ContainerNodePoolResource) Marshal() ([]byte, error) { + return json.Marshal(&r.n) +} + +// Name returns the name given to the cluster nodepool +func (r *ContainerNodePoolResource) Name() string { + return r.n.Name +} + +// IsLegacyMetadataAPIDisabled returns whether the given Node Pool has legacy metadata APIs disabled +func (r *ContainerNodePoolResource) IsLegacyMetadataAPIDisabled() (result bool, err error) { + var val string + var ok bool + if val, ok = r.n.Config.Metadata["disable-legacy-endpoints"]; !ok { + err = errors.New("Could not find key 'disable-legacy-endpoints'") + } + if val != "true" { + err = fmt.Errorf("Invalid value for `disable-legacy-endpoints`, got `%v'", val) + } + result = err == nil + return +} + +// IsAutoRepairEnabled returns whether a Node Pool is configured to automatically repair on error +func (r *ContainerNodePoolResource) IsAutoRepairEnabled() bool { + return r.n.Management.AutoRepair +} + +// IsAutoUpgradeEnabled returns whether a Node Pool is configured to automatically upgrade GKE versions +func (r *ContainerNodePoolResource) IsAutoUpgradeEnabled() bool { + return r.n.Management.AutoUpgrade +} + +// CheckDistributionTypeIs returns whether a Node Pool's OS distribution is the expected type +func (r *ContainerNodePoolResource) CheckDistributionTypeIs(expected string) (result bool, err error) { + result = r.n.Config.ImageType == expected + if !result { + err = fmt.Errorf("Node pool is using %v, not %v", r.n.Config.ImageType, expected) + } + return +} diff --git a/pkg/resource/gcp/container_nodepool_test.go b/pkg/resource/gcp/container_nodepool_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/container_nodepool_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/flags.go b/pkg/resource/gcp/flags.go new file mode 100644 index 0000000..f496cc6 --- /dev/null +++ b/pkg/resource/gcp/flags.go @@ -0,0 +1,37 @@ +package gcp + +import ( + "flag" + "strconv" + "strings" + + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" +) + +var ( + // Default Values + defaultOAuthMinimalScopes = "https://www.googleapis.com/auth/devstorage.read_only,https://www.googleapis.com/auth/logging.write,https://www.googleapis.com/auth/monitoring,https://www.googleapis.com/auth/servicecontrol,https://www.googleapis.com/auth/service.management.readonly,https://www.googleapis.com/auth/trace.append" + + // Values + userDomains = []string{} + minimalOAuthScopes = []string{} + saKeyExpirationTime = 0 + + // Flags + flagContainerMinimalOAuthScopes = flag.String("container.oauth-scopes", utils.GetEnv("NEMESIS_CONTAINER_OAUTHSCOPES", defaultOAuthMinimalScopes), "A comma-seperated list of OAuth scopes to allow for GKE clusters") + flagUserDomains = flag.String("iam.user-domains", utils.GetEnv("NEMESIS_IAM_USERDOMAINS", ""), "A comma-separated list of domains to allow users from") + flagSAKeyExpirationTime = flag.String("iam.sa-key-expiration-time", utils.GetEnv("NEMESIS_IAM_SA_KEY_EXPIRATION_TIME", "90"), "The time in days to allow service account keys to live before being rotated") +) + +func init() { + var err error + + minimalOAuthScopes = strings.Split(*flagContainerMinimalOAuthScopes, ",") + userDomains = strings.Split(*flagUserDomains, ",") + saKeyExpirationTime, err = strconv.Atoi(*flagSAKeyExpirationTime) + + if err != nil { + glog.Fatalf("Failed to convert SA key expiration time to an integer: %v", err) + } +} diff --git a/pkg/resource/gcp/iam_policy.go b/pkg/resource/gcp/iam_policy.go new file mode 100644 index 0000000..042b618 --- /dev/null +++ b/pkg/resource/gcp/iam_policy.go @@ -0,0 +1,310 @@ +package gcp + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" +) + +const ( + // Rules to check + editorRole = "roles/editor" + serviceAccountUserRole = "roles/iam.serviceAccountUser" + serviceAccountAdminRole = "roles/iam.serviceAccountAdmin" + kmsAdminRole = "roles/cloudkms.admin" + kmsRoleMatcher = "roles/cloudkms." +) + +var ( + // Cloud Audit log types + logTypes = []string{"ADMIN_READ", "DATA_READ", "DATA_WRITE"} +) + +// Helper functions for identifying various types of users or roles +func isGCPAccount(member string) bool { + return strings.Contains(member, "developer.gserviceaccount.com") || strings.Contains(member, "appspot.gserviceaccount.com") +} + +func isAdminRole(role string) bool { + return strings.Contains(role, "admin") || strings.Contains(role, "owner") || strings.Contains(role, "editor") +} + +func isIAMUserMember(member string) bool { + return strings.Contains(member, "user:") || strings.Contains(member, "group:") || strings.Contains(member, "domain:") +} + +// IamPolicyResource is resource for testing IAM Policies in GCP +type IamPolicyResource struct { + p *cloudresourcemanager.Policy +} + +// NewIamPolicyResource returns a new IamPolicyResource +func NewIamPolicyResource(p *cloudresourcemanager.Policy) *IamPolicyResource { + r := new(IamPolicyResource) + r.p = p + return r +} + +// Marshal returns the underlying resource's JSON representation +func (r *IamPolicyResource) Marshal() ([]byte, error) { + return json.Marshal(&r.p) +} + +// PolicyViolatesUserDomainWhitelist returns whether the policy contains a user or domain that is not part of the domain whitelist +func (r *IamPolicyResource) PolicyViolatesUserDomainWhitelist() (err error) { + + var errBuilder strings.Builder + for _, b := range r.p.Bindings { + for _, member := range b.Members { + if isIAMUserMember(member) { + for _, domain := range userDomains { + if !strings.Contains(member, domain) { + errBuilder.WriteString(fmt.Sprintf("%v is not allowed by your domain whitelist. ", member)) + } + } + } + } + } + + // If we collected errors, report a failure + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } else { + err = nil + } + + return +} + +// MemberHasAdminRole returns whether a given member has an admin role +func (r *IamPolicyResource) MemberHasAdminRole(member string) (err error) { + + for _, b := range r.p.Bindings { + for _, m := range b.Members { + if member == m { + + // Found the member, now check if it has an admin role + if isAdminRole(b.Role) { + + // Allow the default compute and appengine service accounts + // to have "editor" role + if isGCPAccount(member) && b.Role == editorRole { + return + } + + err = fmt.Errorf("Member has admin role %v", b.Role) + } + break + } + } + } + + return +} + +// PolicyAllowsIAMUserServiceAccountUserRole checks whether the policy allows non-service account +// users to impersonate a service account (privelage escalation) +func (r *IamPolicyResource) PolicyAllowsIAMUserServiceAccountUserRole() (err error) { + + var errBuilder strings.Builder + + for _, b := range r.p.Bindings { + if b.Role == serviceAccountUserRole { + for _, member := range b.Members { + if isIAMUserMember(member) { + errBuilder.WriteString(fmt.Sprintf("%v has Service Account User role. ", member)) + } + } + break + } + } + + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } + + return +} + +func (r *IamPolicyResource) findMembersWithOverlappingRoles(roleA string, roleB string) []string { + + aMembers := []string{} + bMembers := []string{} + + for _, b := range r.p.Bindings { + + // Check for members that have the A role. If we don't that role, + // then there's nothing to check + if b.Role == roleA { + + // Now check for a binding with the user role + for _, bb := range r.p.Bindings { + + // If we find a binding, then we need to check for overlap. + if bb.Role == roleB { + aMembers = b.Members + bMembers = bb.Members + } + break + } + break + } + } + + overlap := []string{} + + // Now compare memberships for overlap + for _, m := range aMembers { + for _, mm := range bMembers { + if m == mm { + overlap = append(overlap, m) + } + } + } + + return overlap +} + +// PolicyViolatesServiceAccountSeparationoOfDuties returns whether the policy allows for IAM users +// to both administrate and impersonate service accounts +func (r *IamPolicyResource) PolicyViolatesServiceAccountSeparationoOfDuties() (err error) { + + // We should report errors when we see a member that has both roles: + // -- roles/iam.serviceAccountUser + // -- roles/iam.serviceAccountAdmin + overlap := r.findMembersWithOverlappingRoles(serviceAccountAdminRole, serviceAccountUserRole) + + var errBuilder strings.Builder + + // Now compare memberships. If there is overlap, report these as errors + for _, m := range overlap { + errBuilder.WriteString(fmt.Sprintf("%v can both administrate and impersonate service accounts. ", m)) + } + + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } + + return +} + +// PolicyViolatesKMSSeparationoOfDuties returns whether the policy allows for KMS users +// to both administrate keyrings and encrypt/decrypt with keys +func (r *IamPolicyResource) PolicyViolatesKMSSeparationoOfDuties() (err error) { + + // We should report errors when we see a member that has the KMS admin role and a non-admin role: + // -- roles/cloudkms.admin + // -- roles/cloudkms.* + + kmsRolesDefined := []string{} + + for _, b := range r.p.Bindings { + + // If we have no admin role, then there's nothing to check + if b.Role == kmsAdminRole { + for _, bb := range r.p.Bindings { + + if bb.Role != kmsAdminRole && strings.Contains(bb.Role, kmsRoleMatcher) { + kmsRolesDefined = append(kmsRolesDefined, bb.Role) + } + } + + break + } + } + + var errBuilder strings.Builder + + // Now check each for overlap + for _, role := range kmsRolesDefined { + overlap := r.findMembersWithOverlappingRoles(kmsAdminRole, role) + for _, member := range overlap { + errBuilder.WriteString(fmt.Sprintf("%v can both administrate and perform actions with %v. ", member, role)) + } + } + + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } + + return +} + +// PolicyConfiguresAuditLogging returns whether the IAM policy defines Cloud Audit logging +func (r *IamPolicyResource) PolicyConfiguresAuditLogging() error { + + // Do we even have an auditConfig? + if r.p.AuditConfigs == nil { + return errors.New("Policy does not define auditConfigs") + } + + if r.p.AuditConfigs[0].Service != "allServices" { + return errors.New("allServices is not the default audit config policy") + } + + if r.p.AuditConfigs[0].AuditLogConfigs == nil { + return errors.New("Policy does not define auditLogConfigs") + } + + // We must have the required number of audit log config types + if len(r.p.AuditConfigs[0].AuditLogConfigs) != len(logTypes) { + return errors.New("Policy does not define all required log types (requires ADMIN_READ, DATA_READ, DATA_WRITE)") + } + + for _, cfg := range r.p.AuditConfigs[0].AuditLogConfigs { + found := false + for _, typ := range logTypes { + if cfg.LogType == typ { + found = true + break + } + } + + if !found { + return errors.New("Policy has an unrecognized auditLogConfig type") + } + } + + return nil +} + +// PolicyDoesNotHaveAuditLogExceptions returns whether the IAM policy allows for exceptions to audit logging +func (r *IamPolicyResource) PolicyDoesNotHaveAuditLogExceptions() error { + + // Do we even have an auditConfig? + if r.p.AuditConfigs == nil { + return errors.New("Policy does not define auditConfigs") + } + + if r.p.AuditConfigs[0].AuditLogConfigs == nil { + return errors.New("Policy does not define auditLogConfigs") + } + + var errBuilder strings.Builder + + for _, cfg := range r.p.AuditConfigs[0].AuditLogConfigs { + if len(cfg.ExemptedMembers) != 0 { + errBuilder.WriteString(fmt.Sprintf("%s has the following exceptions: ", cfg.LogType)) + for _, exempt := range cfg.ExemptedMembers { + errBuilder.WriteString(exempt) + errBuilder.WriteString(",") + } + errBuilder.WriteString(". ") + } + } + + errString := errBuilder.String() + + if len(errString) != 0 { + return errors.New(errString) + } + + return nil +} diff --git a/pkg/resource/gcp/iam_policy_test.go b/pkg/resource/gcp/iam_policy_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/iam_policy_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/iam_serviceaccount.go b/pkg/resource/gcp/iam_serviceaccount.go new file mode 100644 index 0000000..9c0e0ed --- /dev/null +++ b/pkg/resource/gcp/iam_serviceaccount.go @@ -0,0 +1,68 @@ +package gcp + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/golang/glog" + iam "google.golang.org/api/iam/v1" +) + +// IamServiceAccountResource is resource for testing IAM Service Accounts in GCP +type IamServiceAccountResource struct { + s *iam.ServiceAccount + Keys []*iam.ServiceAccountKey +} + +// NewIamServiceAccountResource returns a new IamServiceAccountResource +func NewIamServiceAccountResource(s *iam.ServiceAccount) *IamServiceAccountResource { + r := new(IamServiceAccountResource) + r.s = s + r.Keys = []*iam.ServiceAccountKey{} + return r +} + +// Email returns the email address of the service account +func (r *IamServiceAccountResource) Email() string { + return r.s.Email +} + +// Marshal returns the underlying resource's JSON representation +func (r *IamServiceAccountResource) Marshal() ([]byte, error) { + return json.Marshal(&r.s) +} + +// HasUserManagedKeys returns whether a service account has user-managed keys +func (r *IamServiceAccountResource) HasUserManagedKeys() bool { + return len(r.Keys) != 0 +} + +// HasKeysNeedingRotation returns an error when the service account has keys older than the allowed time +func (r *IamServiceAccountResource) HasKeysNeedingRotation() (err error) { + + var errBuilder strings.Builder + + for _, k := range r.Keys { + t, err := time.Parse(time.RFC3339, k.ValidAfterTime) + if err != nil { + glog.Fatalf("Failed to parse timestamp when checking keys: %v", err) + } + + if t.Sub(time.Now()).Hours() > float64(saKeyExpirationTime*24) { + errBuilder.WriteString(fmt.Sprintf("%v has key older than %v days. ", k.Name, saKeyExpirationTime)) + } + + } + + errString := errBuilder.String() + if errString != "" { + err = errors.New(errString) + } else { + err = nil + } + + return +} diff --git a/pkg/resource/gcp/iam_serviceaccount_test.go b/pkg/resource/gcp/iam_serviceaccount_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/iam_serviceaccount_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/logging_metric.go b/pkg/resource/gcp/logging_metric.go new file mode 100644 index 0000000..5f2ab4a --- /dev/null +++ b/pkg/resource/gcp/logging_metric.go @@ -0,0 +1,27 @@ +package gcp + +import ( + loggingpb "google.golang.org/genproto/googleapis/logging/v2" +) + +// LoggingMetricResource represents a StackDriver log-based metric +type LoggingMetricResource struct { + m *loggingpb.LogMetric +} + +// NewLoggingMetricResource returns a new LoggingMetricResource +func NewLoggingMetricResource(metric *loggingpb.LogMetric) *LoggingMetricResource { + r := new(LoggingMetricResource) + r.m = metric + return r +} + +// Filter returns the filter of the metric +func (r *LoggingMetricResource) Filter() string { + return r.m.Filter +} + +// FilterMatches returns whether the configured filter matches a given string +func (r *LoggingMetricResource) FilterMatches(filter string) bool { + return r.m.Filter == filter +} diff --git a/pkg/resource/gcp/logging_metric_test.go b/pkg/resource/gcp/logging_metric_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/logging_metric_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/logging_sink.go b/pkg/resource/gcp/logging_sink.go new file mode 100644 index 0000000..60685d6 --- /dev/null +++ b/pkg/resource/gcp/logging_sink.go @@ -0,0 +1,25 @@ +package gcp + +import ( + loggingpb "google.golang.org/genproto/googleapis/logging/v2" +) + +// LoggingSinkResource represents a StackDriver logging sink +type LoggingSinkResource struct { + s *loggingpb.LogSink +} + +// NewLoggingSinkResource returns a new LoggingSinkResource +func NewLoggingSinkResource(sink *loggingpb.LogSink) *LoggingSinkResource { + r := new(LoggingSinkResource) + r.s = sink + return r +} + +// ShipsAllLogs indicates whether there is no filter (and thus all logs are shipped) +func (r *LoggingSinkResource) ShipsAllLogs() bool { + + // An empty string indicates that there is no filter - thus all logs + // that are generated are shipped to the logging sink destination + return r.s.Filter == "" +} diff --git a/pkg/resource/gcp/logging_sink_test.go b/pkg/resource/gcp/logging_sink_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/logging_sink_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/serviceusage.go b/pkg/resource/gcp/serviceusage.go new file mode 100644 index 0000000..04f79d7 --- /dev/null +++ b/pkg/resource/gcp/serviceusage.go @@ -0,0 +1,29 @@ +package gcp + +import ( + "encoding/json" + + serviceusage "google.golang.org/api/serviceusage/v1" +) + +// ServiceAPIResource represents a Google Service API resource +type ServiceAPIResource struct { + a *serviceusage.GoogleApiServiceusageV1Service +} + +// NewServiceAPIResource returns a new ServiceAPIResource +func NewServiceAPIResource(a *serviceusage.GoogleApiServiceusageV1Service) *ServiceAPIResource { + r := new(ServiceAPIResource) + r.a = a + return r +} + +// Name returns the bucket's name +func (r *ServiceAPIResource) Name() string { + return r.a.Name +} + +// Marshal returns the underlying resource's JSON representation +func (r *ServiceAPIResource) Marshal() ([]byte, error) { + return json.Marshal(&r.a) +} diff --git a/pkg/resource/gcp/serviceusage_test.go b/pkg/resource/gcp/serviceusage_test.go new file mode 100644 index 0000000..67580e4 --- /dev/null +++ b/pkg/resource/gcp/serviceusage_test.go @@ -0,0 +1 @@ +package gcp diff --git a/pkg/resource/gcp/storage_bucket.go b/pkg/resource/gcp/storage_bucket.go new file mode 100644 index 0000000..1d6e875 --- /dev/null +++ b/pkg/resource/gcp/storage_bucket.go @@ -0,0 +1,83 @@ +package gcp + +import ( + "encoding/json" + "fmt" + + storage "google.golang.org/api/storage/v1" +) + +// StorageBucketResource represents a Google Storage bucket resource +type StorageBucketResource struct { + b *storage.Bucket +} + +// NewStorageBucketResource returns a new StorageBucketResource +func NewStorageBucketResource(b *storage.Bucket) *StorageBucketResource { + r := new(StorageBucketResource) + r.b = b + return r +} + +// Name returns the bucket's name +func (r *StorageBucketResource) Name() string { + return r.b.Name +} + +// Marshal returns the underlying resource's JSON representation +func (r *StorageBucketResource) Marshal() ([]byte, error) { + return json.Marshal(&r.b) +} + +// AllowAllUsers checks whether a bucket is configured to be world readable +func (r *StorageBucketResource) AllowAllUsers() (result bool) { + + acls := r.b.Acl + + for _, acl := range acls { + + // The `allUsers` entity denotes public access + if acl.Entity == "allUsers" { + result = true + return + } + } + + return +} + +// AllowAllAuthenticatedUsers checks whether a bucket is configured to be readable by anyone with a Google account +func (r *StorageBucketResource) AllowAllAuthenticatedUsers() (result bool) { + + acls := r.b.Acl + + for _, acl := range acls { + + // The `allAuthenticatedUsers` entity denotes access to any authenticated user to Google + if acl.Entity == "allAuthenticatedUsers" { + result = true + return + } + } + + return +} + +// HasBucketPolicyOnlyEnabled checks whether a bucket is configured to use permissions across the entire bucket +func (r *StorageBucketResource) HasBucketPolicyOnlyEnabled() (result bool, err error) { + + result = false + iamConfig := r.b.IamConfiguration + + if iamConfig == nil { + err = fmt.Errorf("Could not retrieve IAM configuration for gs://%v", r.b.Name) + return + } + + // Check if the policy exists. If not, then pass + if bucketPolicyOnly := iamConfig.BucketPolicyOnly; bucketPolicyOnly != nil { + // If the policy exists, return whether it is enabled + result = bucketPolicyOnly.Enabled + } + return result, err +} diff --git a/pkg/resource/gcp/storage_bucket_test.go b/pkg/resource/gcp/storage_bucket_test.go new file mode 100644 index 0000000..c71e4b1 --- /dev/null +++ b/pkg/resource/gcp/storage_bucket_test.go @@ -0,0 +1,146 @@ +package gcp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + storage "google.golang.org/api/storage/v1" +) + +// Helper function for making fake bucket resources +func makeTestBucket(data []byte) *storage.Bucket { + bucket := new(storage.Bucket) + _ = json.Unmarshal(data, &bucket) + return bucket +} + +var ( + testValidBucketData = []byte(` +{ + "acl": [ + { + "bucket": "my-test-bucket", + "entity": "project-owners-01010101010101", + "etag": "CAE=", + "id": "my-test-bucket/project-owners-01010101010101", + "kind": "storage#bucketAccessControl", + "projectTeam": { + "projectNumber": "01010101010101", + "team": "owners" + }, + "role": "OWNER", + "selfLink": "https://www.googleapis.com/storage/v1/b/my-test-bucket/acl/project-owners-01010101010101" + }, + { + "bucket": "my-test-bucket", + "entity": "project-editors-01010101010101", + "etag": "CAE=", + "id": "my-test-bucket/project-editors-01010101010101", + "kind": "storage#bucketAccessControl", + "projectTeam": { + "projectNumber": "01010101010101", + "team": "editors" + }, + "role": "OWNER", + "selfLink": "https://www.googleapis.com/storage/v1/b/my-test-bucket/acl/project-editors-01010101010101" + }, + { + "bucket": "my-test-bucket", + "entity": "project-viewers-01010101010101", + "etag": "CAE=", + "id": "my-test-bucket/project-viewers-01010101010101", + "kind": "storage#bucketAccessControl", + "projectTeam": { + "projectNumber": "01010101010101", + "team": "viewers" + }, + "role": "READER", + "selfLink": "https://www.googleapis.com/storage/v1/b/my-test-bucket/acl/project-viewers-01010101010101" + } + ], + "etag": "CAE=", + "iamConfiguration": { + "bucketPolicyOnly": {} + }, + "id": "my-test-bucket", + "kind": "storage#bucket", + "location": "US-CENTRAL1", + "metageneration": "1", + "name": "my-test-bucket", + "projectNumber": "01010101010101", + "selfLink": "https://www.googleapis.com/storage/v1/b/my-test-bucket", + "storageClass": "REGIONAL", + "timeCreated": "2019-01-18T14:14:07.472Z", + "updated": "2019-01-18T14:14:07.472Z" +} +`) + + testValidBucket = makeTestBucket(testValidBucketData) +) + +// Make sure that a bucket resource is created correctly +func TestNewStorageBucketResource(t *testing.T) { + + bucketResource := NewStorageBucketResource(testValidBucket) + + // Make sure the resource is not nil + assert.NotNil(t, bucketResource) + + // Make sure the underlying datasource is not nil + assert.NotNil(t, bucketResource.b) +} + +func TestStorageBucketResourceName(t *testing.T) { + + bucketResource := NewStorageBucketResource(testValidBucket) + + // Make sure the name checks out + assert.Equal(t, "my-test-bucket", bucketResource.Name()) + +} +func TestStorageBucketResourceMarshal(t *testing.T) { + + bucketResource := NewStorageBucketResource(testValidBucket) + + // Marshal the original bucket + orig, err := json.Marshal(&testValidBucket) + + // Make sure the data returns the same as we put in + data, err := bucketResource.Marshal() + assert.Nil(t, err) + + assert.Equal(t, orig, data) +} + +func TestStorageBucketResourceAllowAllUsers(t *testing.T) { + + // Assert that the bucket does not contain the `allUsers` entity + bucketResource := NewStorageBucketResource(testValidBucket) + exists := bucketResource.AllowAllUsers() + assert.False(t, exists) + + // TODO - add a bucket with the `allUsers` entity and check that it works + +} +func TestStorageBucketResourceAllowAllAuthenticatedUsers(t *testing.T) { + + // Assert that the bucket does not contain the `allAuthenticatedUsers` entity + bucketResource := NewStorageBucketResource(testValidBucket) + exists := bucketResource.AllowAllAuthenticatedUsers() + assert.False(t, exists) + + // TODO - add a bucket with the `allAuthenticatedUsers` entity and check that it works + +} +func TestStorageBucketResourceHasBucketPolicyOnlyEnabled(t *testing.T) { + + // Assert that the bucket does not have the BucketPolicyOnly IAM configuration + bucketResource := NewStorageBucketResource(testValidBucket) + exists, err := bucketResource.HasBucketPolicyOnlyEnabled() + assert.Nil(t, err) + assert.False(t, exists) + + // TODO - add a bucket with the BucketPolicyOnly IAM configuration and test it + +} diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go new file mode 100644 index 0000000..6857f1b --- /dev/null +++ b/pkg/runner/runner.go @@ -0,0 +1,136 @@ +// Package runner executes a configured audit +package runner + +import ( + "flag" + + "github.com/Unity-Technologies/nemesis/pkg/client" + "github.com/Unity-Technologies/nemesis/pkg/report" + "github.com/Unity-Technologies/nemesis/pkg/utils" + "github.com/golang/glog" +) + +var ( + flagReportEnableStdout = flag.Bool("reports.stdout.enable", utils.GetEnvBool("NEMESIS_ENABLE_STDOUT"), "Enable outputting report via stdout") + flagReportEnablePubsub = flag.Bool("reports.pubsub.enable", utils.GetEnvBool("NEMESIS_ENABLE_PUBSUB"), "Enable outputting report via Google Pub/Sub") + flagReportPubsubProject = flag.String("reports.pubsub.project", utils.GetEnv("NEMESIS_PUBSUB_PROJECT", ""), "Indicate which GCP project to output Pub/Sub reports to") + flagReportPubsubTopic = flag.String("reports.pubsub.topic", utils.GetEnv("NEMESIS_PUBSUB_TOPIC", "nemesis"), "Indicate which topic to output Pub/Sub reports to") +) + +// Audit is a runner that encapsulates the logic of an audit against GCP resources +type Audit struct { + c *client.Client + reports []report.Report + reporters []report.Reporter +} + +// NewAudit returns a new Audit runner +func NewAudit() *Audit { + a := new(Audit) + a.reports = []report.Report{} + a.reporters = []report.Reporter{} + return a +} + +// Setup configures an Audit runner and sets up audit resources +func (a *Audit) Setup() { + a.c = client.New() + + a.setupReporters() + + if err := a.c.GetProjects(); err != nil { + glog.Fatalf("Failed to retrieve project resources: %v", err) + } + + if err := a.c.GetIamResources(); err != nil { + glog.Fatalf("Failed to retrieve iam resources: %v", err) + } + + if err := a.c.GetComputeResources(); err != nil { + glog.Fatalf("Failed to retrieve compute resources: %v", err) + } + + if err := a.c.GetLoggingResources(); err != nil { + glog.Fatalf("Failed to retrieve logging resources: %v", err) + } + + if err := a.c.GetNetworkResources(); err != nil { + glog.Fatalf("Failed to retrieve network resources: %v", err) + } + + if err := a.c.GetContainerResources(); err != nil { + glog.Fatalf("Failed to retrieve container resources: %v", err) + } + + if err := a.c.GetStorageResources(); err != nil { + glog.Fatalf("Failed to retrieve storage resources: %v", err) + } +} + +func (a *Audit) setupReporters() { + // If pubsub client is required, create it here + if *flagReportEnablePubsub { + + if *flagReportPubsubProject == "" { + glog.Fatal("PubSub project not specified") + + } + if *flagReportPubsubTopic == "" { + glog.Fatal("PubSub topic not specified") + } + + // Create the pubsub client + a.reporters = append(a.reporters, report.NewPubSubReporter(*flagReportPubsubProject, *flagReportPubsubTopic)) + } + + // Setup stdout + if *flagReportEnableStdout { + a.reporters = append(a.reporters, report.NewStdOutReporter()) + } +} + +// Execute performs the configured audits concurrently to completion +func (a *Audit) Execute() { + + // Setup goroutines for each set of reports we need to collect + // TODO - how to make this list dynamic? + generators := []func() (reports []report.Report, err error){ + a.c.GenerateComputeMetadataReports, + a.c.GenerateComputeInstanceReports, + a.c.GenerateLoggingReports, + a.c.GenerateComputeNetworkReports, + a.c.GenerateComputeSubnetworkReports, + a.c.GenerateComputeFirewallRuleReports, + a.c.GenerateComputeAddressReports, + a.c.GenerateIAMPolicyReports, + a.c.GenerateStorageBucketReports, + a.c.GenerateContainerClusterReports, + a.c.GenerateContainerNodePoolReports, + } + + for _, f := range generators { + reports, err := f() + if err != nil { + glog.Fatalf("Failed to generate reports: %v", err) + } + a.reports = append(a.reports, reports...) + } + +} + +// Report exports the configured reports to their final destination +func (a *Audit) Report() { + + // Push metrics + if err := a.c.PushMetrics(); err != nil { + glog.Fatalf("Failed to push metrics: %v", err) + } + + // Push outputs + for _, r := range a.reporters { + err := r.Publish(a.reports) + if err != nil { + glog.Fatalf("Failed to publish reports: %v", err) + } + } +} diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go new file mode 100644 index 0000000..75c10db --- /dev/null +++ b/pkg/runner/runner_test.go @@ -0,0 +1 @@ +package runner diff --git a/pkg/utils/env.go b/pkg/utils/env.go new file mode 100644 index 0000000..9630d00 --- /dev/null +++ b/pkg/utils/env.go @@ -0,0 +1,32 @@ +package utils + +import ( + "os" + "strconv" +) + +// GetEnv returns a string based on the OS environment variable, and returns a default value if not found +func GetEnv(key string, defaultVal string) string { + if envVal, ok := os.LookupEnv(key); ok { + return envVal + } + return defaultVal +} + +// GetEnvBool returns a boolean based on the OS environment variable, and returns false if not found +func GetEnvBool(key string) (envValBool bool) { + if envVal, ok := os.LookupEnv(key); ok { + envValBool, _ = strconv.ParseBool(envVal) + } + return +} + +// GetEnvInt retuns an integer based on the OS environment variable, and returns a default value if not found +func GetEnvInt(key string, defaultVal int) int { + if envVal, ok := os.LookupEnv(key); ok { + if val, ok := strconv.ParseInt(envVal, 0, 0); ok == nil { + return int(val) + } + } + return defaultVal +} diff --git a/pkg/utils/env_test.go b/pkg/utils/env_test.go new file mode 100644 index 0000000..97645fa --- /dev/null +++ b/pkg/utils/env_test.go @@ -0,0 +1,51 @@ +package utils + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetEnv(t *testing.T) { + // Assert correct value + err := os.Setenv("TEST_STRING", "my_value") + assert.Nil(t, err) + assert.Equal(t, "my_value", GetEnv("TEST_STRING", "my_value")) + + // Assert default value + assert.Equal(t, "unset_value", GetEnv("UNSET_TEST_STRING", "unset_value")) + + // Assert incorrect value + err = os.Setenv("TEST_STRING", "updated_value") + assert.Nil(t, err) + assert.False(t, "old_value" == GetEnv("TEST_STRING", "old_value")) +} + +func TestGetEnvBool(t *testing.T) { + + trues := []string{"1", "t", "T", "true", "TRUE", "True"} + falses := []string{"0", "f", "F", "false", "FALSE", "False"} + invalid := []string{"set", "foo", "bar", "2", "@"} + + // Assert true values are parsed correctly + for _, s := range trues { + err := os.Setenv("TEST_BOOL", s) + assert.Nil(t, err) + assert.True(t, GetEnvBool("TEST_BOOL")) + } + + // Assert false values are parsed correctly + for _, s := range falses { + err := os.Setenv("TEST_BOOL", s) + assert.Nil(t, err) + assert.False(t, GetEnvBool("TEST_BOOL")) + } + + // Assert non-boolean values return false + for _, s := range invalid { + err := os.Setenv("TEST_BOOL", s) + assert.Nil(t, err) + assert.False(t, GetEnvBool("TEST_BOOL")) + } +} diff --git a/pkg/utils/flags.go b/pkg/utils/flags.go new file mode 100644 index 0000000..b604142 --- /dev/null +++ b/pkg/utils/flags.go @@ -0,0 +1,7 @@ +package utils + +import "flag" + +var ( + flagDebug = flag.Bool("debug", GetEnvBool("NEMESIS_DEBUG"), "Enable verbose output for debugging") +) diff --git a/pkg/utils/timing.go b/pkg/utils/timing.go new file mode 100644 index 0000000..93809d4 --- /dev/null +++ b/pkg/utils/timing.go @@ -0,0 +1,17 @@ +package utils + +import ( + "fmt" + "time" +) + +// Elapsed sets up a goroutine to indicate how long it took to run a segment of code +func Elapsed(what string) func() { + if *flagDebug { + start := time.Now() + return func() { + fmt.Printf("%s took %v\n", what, time.Since(start)) + } + } + return func() {} +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..cdb4092 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,86 @@ +package version + +import ( + "bytes" + "fmt" +) + +var ( + // The git commit that was compiled. This will be filled in by the compiler. + GitCommit string + GitDescribe string + + // The main version number that is being run at the moment. + Version = "0.0.1" + + // A pre-release marker for the version. If this is "" (empty string) + // then it means that it is a final release. Otherwise, this is a pre-release + // such as "dev" (in development), "beta", "rc1", etc. + VersionPrerelease = "dev" + + // VersionMetadata is metadata further describing the build type. + VersionMetadata = "" +) + +// Info contains info about the binary's version +type Info struct { + Revision string + Version string + VersionPrerelease string + VersionMetadata string +} + +// GetVersion is a utility for retrieiving the current version +func GetVersion() *Info { + ver := Version + rel := VersionPrerelease + md := VersionMetadata + if GitDescribe != "" { + ver = GitDescribe + } + if GitDescribe == "" && rel == "" && VersionPrerelease != "" { + rel = "dev" + } + + return &Info{ + Revision: GitCommit, + Version: ver, + VersionPrerelease: rel, + VersionMetadata: md, + } +} + +// VersionNumber builds a smaller string describing the nemesis version +func (c *Info) VersionNumber() string { + version := fmt.Sprintf("%s", c.Version) + + if c.VersionPrerelease != "" { + version = fmt.Sprintf("%s-%s", version, c.VersionPrerelease) + } + + if c.VersionMetadata != "" { + version = fmt.Sprintf("%s+%s", version, c.VersionMetadata) + } + + return version +} + +// FullVersionNumber builds the string describing the nemesis version +func (c *Info) FullVersionNumber(rev bool) string { + var versionString bytes.Buffer + + fmt.Fprintf(&versionString, "nemesis v%s", c.Version) + if c.VersionPrerelease != "" { + fmt.Fprintf(&versionString, "-%s", c.VersionPrerelease) + } + + if c.VersionMetadata != "" { + fmt.Fprintf(&versionString, "+%s", c.VersionMetadata) + } + + if rev && c.Revision != "" { + fmt.Fprintf(&versionString, " (%s)", c.Revision) + } + + return versionString.String() +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..f37d99d --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1 @@ +package version