├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ └── tests.yml
├── data.tf
├── terraform-modules
├── function
│ ├── build
│ │ └── .gitkeep
│ ├── locals.tf
│ ├── code
│ │ ├── cname
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ │ ├── ns
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ │ ├── astorage
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ │ └── cnamestorage
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ ├── variables.tf
│ └── main.tf
├── function-slack
│ ├── build
│ │ └── .gitkeep
│ ├── code
│ │ └── notify
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ ├── variables.tf
│ ├── locals.tf
│ └── main.tf
├── function-projects
│ ├── build
│ │ └── .gitkeep
│ ├── locals.tf
│ ├── code
│ │ └── projects
│ │ │ ├── requirements.txt
│ │ │ └── main.py
│ ├── variables.tf
│ └── main.tf
├── iam
│ ├── variables.tf
│ ├── locals.tf
│ ├── outputs.tf
│ └── main.tf
├── storage
│ ├── locals.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── main.tf
├── iam-eventarc
│ ├── locals.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── main.tf
├── pubsub-projects
│ ├── locals.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── main.tf
├── pubsub-results
│ ├── locals.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── main.tf
├── pubsub-scheduler
│ ├── locals.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── main.tf
├── secret-manager
│ ├── locals.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── main.tf
└── services
│ ├── outputs.tf
│ └── main.tf
├── backend.tf
├── .config
├── sast_python_bandit_cli.yml
├── sast_python_bandit_json.yml
├── sast_terraform_checkov_cli.yml
└── sast_terraform_checkov_json.yml
├── images
├── slack-gcp.png
├── vulnerable-ns.png
├── deploy-pipeline.png
├── gcp-architecture.png
├── google-services.png
├── pubsub-permissions.png
└── robot-permissions.png
├── manual-scans
├── images
│ ├── gcp-ns.png
│ ├── gcp-cname.png
│ ├── gcp-a-storage.png
│ └── gcp-cname-storage.png
├── requirements.txt
├── utils_print.py
├── README.md
├── utils_gcp.py
├── gcp-ns.py
├── gcp-a-storage.py
├── gcp-cname.py
└── gcp-cname-storage.py
├── requirements-dev.txt
├── locals.tf
├── terraform.tfvars.example
├── .gitignore
├── provider.tf
├── LICENSE.md
├── outputs.tf
├── variables.tf
├── main.tf
└── README.md
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @ovotech/infosec
--------------------------------------------------------------------------------
/data.tf:
--------------------------------------------------------------------------------
1 | data "google_project" "project" {}
--------------------------------------------------------------------------------
/terraform-modules/function/build/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/terraform-modules/function-slack/build/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/terraform-modules/function-projects/build/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | backend "gcs" {
3 | }
4 | }
5 |
--------------------------------------------------------------------------------
/terraform-modules/function-slack/code/notify/requirements.txt:
--------------------------------------------------------------------------------
1 | requests==2.32.4
2 |
--------------------------------------------------------------------------------
/terraform-modules/iam/variables.tf:
--------------------------------------------------------------------------------
1 | variable "name" {}
2 | variable "project" {}
3 |
--------------------------------------------------------------------------------
/.config/sast_python_bandit_cli.yml:
--------------------------------------------------------------------------------
1 | [bandit]
2 | format: screen
3 | recursive: true
4 |
--------------------------------------------------------------------------------
/terraform-modules/iam/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/storage/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/function/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/iam-eventarc/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/iam-eventarc/variables.tf:
--------------------------------------------------------------------------------
1 | variable "name" {}
2 | variable "project" {}
3 |
--------------------------------------------------------------------------------
/terraform-modules/function-projects/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/pubsub-projects/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/pubsub-projects/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {}
2 | variable "name" {}
3 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-results/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/pubsub-results/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {}
2 | variable "name" {}
3 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-scheduler/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/terraform-modules/secret-manager/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 | }
--------------------------------------------------------------------------------
/.config/sast_python_bandit_json.yml:
--------------------------------------------------------------------------------
1 | [bandit]
2 | format: json
3 | recursive: true
4 | ignore-nosec: true
5 |
--------------------------------------------------------------------------------
/images/slack-gcp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/slack-gcp.png
--------------------------------------------------------------------------------
/images/vulnerable-ns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/vulnerable-ns.png
--------------------------------------------------------------------------------
/images/deploy-pipeline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/deploy-pipeline.png
--------------------------------------------------------------------------------
/images/gcp-architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/gcp-architecture.png
--------------------------------------------------------------------------------
/images/google-services.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/google-services.png
--------------------------------------------------------------------------------
/images/pubsub-permissions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/pubsub-permissions.png
--------------------------------------------------------------------------------
/images/robot-permissions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/images/robot-permissions.png
--------------------------------------------------------------------------------
/terraform-modules/storage/outputs.tf:
--------------------------------------------------------------------------------
1 | output "bucket_name" {
2 | value = google_storage_bucket.function_bucket.name
3 | }
4 |
--------------------------------------------------------------------------------
/manual-scans/images/gcp-ns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/manual-scans/images/gcp-ns.png
--------------------------------------------------------------------------------
/terraform-modules/pubsub-projects/outputs.tf:
--------------------------------------------------------------------------------
1 | output "pubsub_topic_name" {
2 | value = google_pubsub_topic.projects.name
3 | }
4 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-results/outputs.tf:
--------------------------------------------------------------------------------
1 | output "pubsub_topic_name" {
2 | value = google_pubsub_topic.results.name
3 | }
4 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-scheduler/outputs.tf:
--------------------------------------------------------------------------------
1 | output "pubsub_topic_name" {
2 | value = google_pubsub_topic.scheduler.name
3 | }
4 |
--------------------------------------------------------------------------------
/manual-scans/images/gcp-cname.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/manual-scans/images/gcp-cname.png
--------------------------------------------------------------------------------
/terraform-modules/iam-eventarc/outputs.tf:
--------------------------------------------------------------------------------
1 | output "service_account_email" {
2 | value = google_service_account.eventarc.email
3 | }
4 |
--------------------------------------------------------------------------------
/terraform-modules/iam/outputs.tf:
--------------------------------------------------------------------------------
1 | output "service_account_email" {
2 | value = google_service_account.function_runtime.email
3 | }
4 |
--------------------------------------------------------------------------------
/manual-scans/images/gcp-a-storage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/manual-scans/images/gcp-a-storage.png
--------------------------------------------------------------------------------
/manual-scans/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-dns==0.35.1
2 | google-cloud-resource-manager==1.14.2
3 | dnspython==2.7.0
4 | requests==2.32.4
5 |
--------------------------------------------------------------------------------
/terraform-modules/function-projects/code/projects/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-pubsub==2.31.0
2 | google-cloud-resource-manager==1.14.2
3 |
--------------------------------------------------------------------------------
/terraform-modules/storage/variables.tf:
--------------------------------------------------------------------------------
1 | variable "region" {
2 | default = ""
3 | }
4 |
5 | variable "name" {
6 | default = ""
7 | }
8 |
--------------------------------------------------------------------------------
/manual-scans/images/gcp-cname-storage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/domain-protect/domain-protect-gcp/HEAD/manual-scans/images/gcp-cname-storage.png
--------------------------------------------------------------------------------
/terraform-modules/secret-manager/variables.tf:
--------------------------------------------------------------------------------
1 | variable "region" {}
2 | variable "app_name" {}
3 | variable "secret_name" {}
4 | variable "secret_value" {}
5 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | black==25.1.0
2 | prospector==1.17.2
3 | google-cloud-dns==0.35.1
4 | google-cloud-resource-manager==1.14.2
5 | dnspython==2.7.0
6 | requests==2.32.4
--------------------------------------------------------------------------------
/terraform-modules/function/code/cname/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-dns==0.35.1
2 | google-cloud-pubsub==2.31.0
3 | google-cloud-resource-manager==1.14.2
4 | dnspython==2.7.0
5 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/ns/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-dns==0.35.1
2 | google-cloud-pubsub==2.31.0
3 | google-cloud-resource-manager==1.14.2
4 | dnspython==2.7.0
5 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/astorage/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-dns==0.35.1
2 | google-cloud-pubsub==2.31.0
3 | google-cloud-resource-manager==1.14.2
4 | requests==2.32.4
5 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/cnamestorage/requirements.txt:
--------------------------------------------------------------------------------
1 | google-cloud-dns==0.35.1
2 | google-cloud-pubsub==2.31.0
3 | google-cloud-resource-manager==1.14.2
4 | requests==2.32.4
5 |
--------------------------------------------------------------------------------
/.config/sast_terraform_checkov_cli.yml:
--------------------------------------------------------------------------------
1 | download-external-modules: false
2 | skip-download: true
3 | evaluate-variables: true
4 | framework:
5 | - terraform
6 | output:
7 | - cli
8 | quiet: true
9 |
--------------------------------------------------------------------------------
/.config/sast_terraform_checkov_json.yml:
--------------------------------------------------------------------------------
1 | download-external-modules: false
2 | skip-download: true
3 | evaluate-variables: true
4 | framework:
5 | - terraform
6 | output:
7 | - json
8 | soft-fail: true
9 |
--------------------------------------------------------------------------------
/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 |
4 | slack_channels = local.env == "dev" ? var.slack_channels_dev : var.slack_channels
5 | secrets = zipmap(local.slack_channels, var.slack_webhook_urls)
6 | }
--------------------------------------------------------------------------------
/terraform-modules/secret-manager/outputs.tf:
--------------------------------------------------------------------------------
1 | output "secret_resource_id" {
2 | value = google_secret_manager_secret.secret.id
3 | }
4 |
5 | output "secret_version_name" {
6 | value = google_secret_manager_secret_version.secret.name
7 | }
8 |
--------------------------------------------------------------------------------
/terraform.tfvars.example:
--------------------------------------------------------------------------------
1 | project = "GCP_PROJECT_ID"
2 | slack_channels = ["devsecops"]
3 | slack_channels_dev = ["devsecops"]
4 | slack_webhook_urls = ["https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXX"]
5 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-scheduler/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {}
2 | variable "app_service_region" {}
3 | variable "create_app_engine" {}
4 | variable "name" {}
5 | variable "time_zone" {}
6 | variable "schedule" {}
7 | variable "schedule_dev" {}
8 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-results/main.tf:
--------------------------------------------------------------------------------
1 | # pubsub topic to trigger functions
2 | resource "google_pubsub_topic" "results" {
3 | # checkov:skip=CKV_GCP_83: not opting to use KMS-based encryption key
4 |
5 | name = "${var.name}-results-${local.env}"
6 | }
7 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-projects/main.tf:
--------------------------------------------------------------------------------
1 | # pubsub topic to trigger functions
2 | resource "google_pubsub_topic" "projects" {
3 | # checkov:skip=CKV_GCP_83: not opting to use KMS-based encryption key
4 |
5 | name = "${var.name}-projects-${local.env}"
6 | }
7 |
--------------------------------------------------------------------------------
/terraform-modules/function-projects/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {}
2 | variable "region" {}
3 | variable "name" {}
4 | variable "bucket_name" {}
5 | variable "ingress_settings" {}
6 | variable "runtime" {}
7 | variable "timeout" {}
8 | variable "pubsub_topic" {}
9 | variable "service_account_email" {}
10 | variable "service_account_eventarc" {}
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "weekly"
8 |
9 | # Maintain dependencies for Python
10 | - package-ecosystem: "pip"
11 | directory: "/"
12 | schedule:
13 | interval: "weekly"
14 | target-branch: "dependencies"
15 |
--------------------------------------------------------------------------------
/terraform-modules/function/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {}
2 | variable "region" {}
3 | variable "name" {}
4 | variable "bucket_name" {}
5 | variable "functions" {}
6 | variable "available_memory" {}
7 | variable "ingress_settings" {}
8 | variable "runtime" {}
9 | variable "timeout" {}
10 | variable "pubsub_topic" {}
11 | variable "service_account_email" {}
12 | variable "service_account_eventarc" {}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Terraform
2 | *.tfstate
3 | *.tfstate.backup
4 | *.tfvars*
5 | !terraform.tfvars.example
6 | .terraform/
7 | .terraform*
8 |
9 | #Function code
10 | *.zip
11 |
12 | #IDEs
13 | .idea
14 | .vscode
15 |
16 | #MAC OS
17 | .DS_Store
18 | *.DS_Store
19 |
20 | #Python coverage
21 | .coverage
22 | */htmlcov/*
23 |
24 | #Python
25 | .venv
26 | .venv/*
27 | __pycache__/*
28 | */.venv
29 | */.venv/*
30 | */__pycache__/*
--------------------------------------------------------------------------------
/terraform-modules/secret-manager/main.tf:
--------------------------------------------------------------------------------
1 | resource "google_secret_manager_secret" "secret" {
2 | secret_id = "${var.app_name}-${var.secret_name}-${local.env}"
3 |
4 | replication {
5 | user_managed {
6 | replicas {
7 | location = var.region
8 | }
9 | }
10 | }
11 | }
12 |
13 | resource "google_secret_manager_secret_version" "secret" {
14 | secret = google_secret_manager_secret.secret.id
15 | secret_data = var.secret_value
16 | }
--------------------------------------------------------------------------------
/terraform-modules/function-slack/variables.tf:
--------------------------------------------------------------------------------
1 | variable "name" {}
2 | variable "project" {}
3 | variable "region" {}
4 | variable "secret_resource_id" {}
5 | variable "secret_version_name" {}
6 | variable "bucket_name" {}
7 | variable "ingress_settings" {}
8 | variable "runtime" {}
9 | variable "timeout" {}
10 | variable "pubsub_topic" {}
11 | variable "service_account_email" {}
12 | variable "service_account_eventarc" {}
13 | variable "slack_channel" {}
14 | variable "slack_emoji" {}
15 | variable "slack_username" {}
16 |
--------------------------------------------------------------------------------
/terraform-modules/function-slack/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | env = lower(terraform.workspace)
3 |
4 | secret_version_list = split("/", var.secret_version_name)
5 | secret_version = element(local.secret_version_list, length(local.secret_version_list) - 1)
6 |
7 | secret_resource_id_list = split("/", var.secret_resource_id)
8 | secret_id = element(local.secret_resource_id_list, length(local.secret_resource_id_list) - 1)
9 |
10 | slack_channel_sanitised = lower(replace(var.slack_channel, "_", "-")) # satisfy Cloud Run naming requirements
11 | }
--------------------------------------------------------------------------------
/provider.tf:
--------------------------------------------------------------------------------
1 | provider "google" {
2 | project = var.project
3 | region = var.region
4 | zone = var.zone
5 | }
6 |
7 | terraform {
8 | required_providers {
9 | google = {
10 | source = "hashicorp/google"
11 | version = "~> 4.66.0"
12 | }
13 | archive = {
14 | source = "hashicorp/archive"
15 | version = "~> 2.2.0"
16 | }
17 | null = {
18 | source = "hashicorp/null"
19 | version = "~> 3.1.0"
20 | }
21 | random = {
22 | source = "hashicorp/random"
23 | version = "~> 3.1.0"
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2021 OVO Energy
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/terraform-modules/storage/main.tf:
--------------------------------------------------------------------------------
1 | # create storage bucket to store function code
2 |
3 | resource "random_string" "value" {
4 | length = 5
5 | special = false
6 | min_lower = 5
7 | }
8 |
9 | resource "google_storage_bucket" "function_bucket" {
10 | # checkov:skip=CKV_GCP_62: logging not needed for S3 bucket used for function code
11 |
12 | name = "${var.name}-${local.env}-${random_string.value.result}"
13 | location = var.region
14 | force_destroy = true
15 | uniform_bucket_level_access = true
16 | public_access_prevention = "enforced"
17 |
18 | versioning {
19 | enabled = true
20 | }
21 | }
--------------------------------------------------------------------------------
/terraform-modules/iam-eventarc/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_string" "value" {
2 | length = 4
3 | special = false
4 | min_lower = 4
5 | }
6 |
7 | resource "google_service_account" "eventarc" {
8 | account_id = "${var.name}-event-${local.env}-${random_string.value.result}"
9 | display_name = "${var.name} Eventarc Trigger ${local.env} "
10 |
11 | provisioner "local-exec" {
12 | command = "sleep 120"
13 | }
14 | }
15 |
16 | resource "google_project_iam_member" "permissions" {
17 | member = "serviceAccount:${google_service_account.eventarc.email}"
18 | project = var.project
19 | for_each = toset(["roles/run.invoker", "roles/eventarc.eventReceiver"])
20 | role = each.key
21 | }
22 |
--------------------------------------------------------------------------------
/terraform-modules/services/outputs.tf:
--------------------------------------------------------------------------------
1 | output "app_engine_service_id" {
2 | value = google_project_service.app_engine.id
3 | }
4 |
5 | output "cloud_functions_service_id" {
6 | value = google_project_service.cloud_functions.id
7 | }
8 |
9 | output "cloud_scheduler_service_id" {
10 | value = google_project_service.cloud_scheduler.id
11 | }
12 |
13 | output "iam_service_id" {
14 | value = google_project_service.iam.id
15 | }
16 |
17 | output "cloud_build_service_id" {
18 | value = google_project_service.cloud_build.id
19 | }
20 |
21 | output "secret_manager_service_id" {
22 | value = google_project_service.secret_manager.id
23 | }
24 |
25 | output "cloud_run_service_id" {
26 | value = google_project_service.cloud_run.id
27 | }
28 |
29 | output "event_arc_service_id" {
30 | value = google_project_service.event_arc.id
31 | }
32 |
--------------------------------------------------------------------------------
/terraform-modules/iam/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_string" "value" {
2 | length = 5
3 | special = false
4 | min_lower = 5
5 | }
6 |
7 | resource "google_service_account" "function_runtime" {
8 | account_id = "${var.name}-${local.env}-${random_string.value.result}"
9 | display_name = "${var.name} function runtime ${local.env} "
10 |
11 | provisioner "local-exec" {
12 | command = "sleep 120"
13 | }
14 | }
15 |
16 | # Project level permissions are set here
17 | # Organization level permissions are set using the console - see README for details
18 | resource "google_project_iam_member" "permissions" {
19 | member = "serviceAccount:${google_service_account.function_runtime.email}"
20 | project = var.project
21 | for_each = toset(["roles/pubsub.publisher", "roles/pubsub.subscriber", "roles/secretmanager.secretAccessor"])
22 | role = each.key
23 | }
24 |
--------------------------------------------------------------------------------
/outputs.tf:
--------------------------------------------------------------------------------
1 | output "Domain_Protect_permissions" {
2 | value = "Use GCP IAM console to grant org level permissions to service account ${module.iam.service_account_email} see https://github.com/domain-protect/domain-protect-gcp#manually-apply-audit-permissions-at-org-level"
3 | }
4 |
5 | output "Pub_Sub_permissions" {
6 | value = "Use GCP IAM console to grant project level permission to Google managed service account service-${data.google_project.project.number}@gcp-sa-pubsub.iam.gserviceaccount.com see https://github.com/domain-protect/domain-protect-gcp#ensure-correct-permissions-on-google-service-accounts"
7 | }
8 |
9 | output "GCF_robot_permissions" {
10 | value = "Use GCP IAM console to grant project level permission to Google managed service account service-${data.google_project.project.number}@gcf-admin-robot.iam.gserviceaccount.com see https://github.com/domain-protect/domain-protect-gcp#ensure-correct-permissions-on-google-service-accounts"
11 | }
12 |
--------------------------------------------------------------------------------
/terraform-modules/pubsub-scheduler/main.tf:
--------------------------------------------------------------------------------
1 | # pubsub topic to trigger functions
2 | resource "google_pubsub_topic" "scheduler" {
3 | # checkov:skip=CKV_GCP_83: not opting to use KMS-based encryption key
4 | name = "${var.name}-scheduler-${local.env}"
5 | }
6 |
7 | # create an app engine application for scheduler to work in the project if none already
8 | resource "google_app_engine_application" "function_scheduler" {
9 | count = var.create_app_engine == true ? 1 : 0
10 | project = var.project
11 | location_id = var.app_service_region
12 | }
13 |
14 | # scheduler - requires App service to be created in the project
15 | resource "google_cloud_scheduler_job" "functions" {
16 | name = "${var.name}-${local.env}"
17 | region = var.app_service_region
18 | description = "Schedule for ${var.name} functions"
19 | schedule = local.env == "dev" ? var.schedule_dev : var.schedule
20 | time_zone = var.time_zone
21 |
22 | pubsub_target {
23 | topic_name = "projects/${var.project}/topics/${google_pubsub_topic.scheduler.name}"
24 | data = "dHJpZ2dlcg==" #Base64 encoding of "trigger"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/terraform-modules/services/main.tf:
--------------------------------------------------------------------------------
1 | # enable apis if needed
2 | # requires serviceusage.googleapis.com to be enabled using console or gcloud
3 | resource "google_project_service" "cloud_scheduler" {
4 | service = "cloudscheduler.googleapis.com"
5 | disable_on_destroy = false
6 | }
7 |
8 | resource "google_project_service" "app_engine" {
9 | service = "appengine.googleapis.com"
10 | disable_on_destroy = false
11 | }
12 |
13 | resource "google_project_service" "cloud_functions" {
14 | service = "cloudfunctions.googleapis.com"
15 | disable_on_destroy = false
16 | }
17 |
18 | resource "google_project_service" "iam" {
19 | service = "iam.googleapis.com"
20 | disable_on_destroy = false
21 | }
22 |
23 | resource "google_project_service" "cloud_build" {
24 | service = "cloudbuild.googleapis.com"
25 | disable_on_destroy = false
26 | }
27 |
28 | resource "google_project_service" "secret_manager" {
29 | service = "secretmanager.googleapis.com"
30 | disable_on_destroy = false
31 | }
32 |
33 | resource "google_project_service" "cloud_run" {
34 | service = "run.googleapis.com"
35 | disable_on_destroy = false
36 | }
37 |
38 | resource "google_project_service" "event_arc" {
39 | service = "eventarc.googleapis.com"
40 | disable_on_destroy = false
41 | }
42 |
--------------------------------------------------------------------------------
/manual-scans/utils_print.py:
--------------------------------------------------------------------------------
1 | class bcolors:
2 | TITLE = "\033[95m"
3 | OKBLUE = "\033[94m"
4 | OKGREEN = "\033[92m"
5 | INFO = "\033[93m"
6 | OKRED = "\033[91m"
7 | ENDC = "\033[0m"
8 | BOLD = "\033[1m"
9 | BGRED = "\033[41m"
10 | UNDERLINE = "\033[4m"
11 | FGWHITE = "\033[37m"
12 | FAIL = "\033[95m"
13 |
14 |
15 | def my_print(text, message_type):
16 | if message_type == "INFO":
17 | print(bcolors.INFO + text + bcolors.ENDC)
18 | return
19 | if message_type == "PLAIN_OUTPUT_WS":
20 | print(bcolors.INFO + text + bcolors.ENDC)
21 | return
22 | if message_type == "INFOB":
23 | print(bcolors.INFO + bcolors.BOLD + text + bcolors.ENDC)
24 | return
25 | if message_type == "ERROR":
26 | print(bcolors.BGRED + bcolors.FGWHITE + bcolors.BOLD + text + bcolors.ENDC)
27 | return
28 | if message_type == "MESSAGE":
29 | print(bcolors.TITLE + bcolors.BOLD + text + bcolors.ENDC + "\n")
30 | return
31 | if message_type == "INSECURE_WS":
32 | print(bcolors.OKRED + bcolors.BOLD + text + bcolors.ENDC)
33 | return
34 | if message_type == "INSECURE":
35 | print(bcolors.OKRED + bcolors.BOLD + text + bcolors.ENDC + "\n")
36 | return
37 | if message_type == "OUTPUT":
38 | print(bcolors.OKBLUE + bcolors.BOLD + text + bcolors.ENDC + "\n")
39 | return
40 | if message_type == "OUTPUT_WS":
41 | print(bcolors.OKBLUE + bcolors.BOLD + text + bcolors.ENDC)
42 | return
43 | if message_type == "SECURE":
44 | print(bcolors.OKGREEN + bcolors.BOLD + text + bcolors.ENDC)
45 |
46 |
47 | def print_list(lst):
48 | counter = 0
49 | for item in lst:
50 | counter = counter + 1
51 | entry = str(counter) + ". " + item
52 | my_print("\t" + entry, "INSECURE_WS")
53 |
--------------------------------------------------------------------------------
/terraform-modules/function-projects/main.tf:
--------------------------------------------------------------------------------
1 | # Dummy resource to ensure archive is created at apply stage
2 | resource "null_resource" "dummy_trigger" {
3 | triggers = {
4 | timestamp = timestamp()
5 | }
6 | }
7 |
8 | # zip source code
9 | data "archive_file" "code" {
10 | type = "zip"
11 | source_dir = "${path.module}/code/projects"
12 | output_path = "${path.module}/build/projects.zip"
13 | depends_on = [
14 | # Make sure archive is created in apply stage
15 | null_resource.dummy_trigger
16 | ]
17 | }
18 |
19 | # upload compressed code to bucket
20 | resource "google_storage_bucket_object" "function" {
21 | name = "projects-${data.archive_file.code.output_md5}.zip"
22 | bucket = var.bucket_name
23 | source = "${path.module}/build/projects.zip"
24 | }
25 |
26 | resource "google_cloudfunctions2_function" "function" {
27 | name = "${var.name}-projects-${local.env}"
28 | description = "${var.name} Lists all projects in Organization and writes to ${local.env} Pub/Sub topic"
29 | location = var.region
30 |
31 | build_config {
32 | runtime = var.runtime
33 | entry_point = "projects"
34 |
35 | source {
36 | storage_source {
37 | bucket = var.bucket_name
38 | object = google_storage_bucket_object.function.name
39 | }
40 | }
41 | }
42 |
43 | service_config {
44 | timeout_seconds = var.timeout
45 | environment_variables = {
46 | SECURITY_PROJECT = var.project
47 | APP_ENVIRONMENT = local.env
48 | APP_NAME = var.name
49 | }
50 | all_traffic_on_latest_revision = true
51 | service_account_email = var.service_account_email
52 | ingress_settings = var.ingress_settings
53 | }
54 |
55 | event_trigger {
56 | trigger_region = var.region
57 | event_type = "google.cloud.pubsub.topic.v1.messagePublished"
58 | pubsub_topic = "projects/${var.project}/topics/${var.pubsub_topic}"
59 | service_account_email = var.service_account_eventarc
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/manual-scans/README.md:
--------------------------------------------------------------------------------
1 | # domain-protect manual scans
2 | Scans Google Cloud DNS for:
3 | * subdomain NS delegations vulnerable to takeover
4 | * CNAMEs vulnerable to takeover
5 | * CNAMEs for missing storage buckets
6 | * Google Cloud Load Balancers for which the backend storage bucket has been deleted
7 |
8 | ## requirements
9 | * Python 3.9
10 | * pip
11 | * venv
12 |
13 | ## Python setup
14 | * optionally create and activate a virtual environment
15 | ```
16 | python -m venv .venv
17 | source .venv/bin/activate
18 | ```
19 | * install dependencies
20 | ```
21 | pip install -r requirements.txt
22 | ```
23 |
24 | ## GCP setup
25 | * create a domain-protect service account in one of your projects
26 | * assign built-in roles to the service account at the organization level:
27 | ```
28 | DNS Reader (roles/dns.reader)
29 | Folder Viewer (roles/resourcemanager.folderViewer)
30 | Organization Viewer (roles/resourcemanager.organizationViewer)
31 | ```
32 | * create a JSON key for your service account and download to your laptop
33 | * create an environment variable, e.g.
34 | ```
35 | export GOOGLE_APPLICATION_CREDENTIALS="/Users/sylvia/gcp/domain-protect-service-account.json"
36 | ```
37 |
38 | ## usage - subdomain NS delegations
39 | ```
40 | python gcp-ns.py
41 | ```
42 |
43 | 
44 |
45 | ## usage - vulnerable CNAMEs
46 | ```
47 | python gcp-cname.py
48 | ```
49 |
50 | 
51 |
52 | ## usage - CNAMEs for missing storage buckets
53 | ```
54 | python gcp-cname-storage.py
55 | ```
56 |
57 | 
58 |
59 | ## usage - A records for missing storage buckets
60 | * looks for Google Cloud Load Balancers for which the backend storage bucket has been deleted
61 | ```
62 | python gcp-a-storage.py
63 | ```
64 |
65 | 
66 |
67 | ## acknowledgements
68 | * Function to list all GCP projects inspired by [Joan Grau's blog](https://blog.graunoel.com/resource-manager-list-all-projects/)
69 | * NS subdomain takeover detection based on [NSDetect](https://github.com/shivsahni/NSDetect)
--------------------------------------------------------------------------------
/terraform-modules/function-slack/main.tf:
--------------------------------------------------------------------------------
1 | # Dummy resource to ensure archive is created at apply stage
2 | resource "null_resource" "dummy_trigger" {
3 | triggers = {
4 | timestamp = timestamp()
5 | }
6 | }
7 |
8 | # zip source code
9 | data "archive_file" "code" {
10 | type = "zip"
11 | source_dir = "${path.module}/code/notify"
12 | output_path = "${path.module}/build/notify.zip"
13 | depends_on = [
14 | # Make sure archive is created in apply stage
15 | null_resource.dummy_trigger
16 | ]
17 | }
18 |
19 | # upload compressed code to bucket
20 | resource "google_storage_bucket_object" "function" {
21 | name = "notify-${data.archive_file.code.output_md5}.zip"
22 | bucket = var.bucket_name
23 | source = "${path.module}/build/notify.zip"
24 | }
25 |
26 | resource "google_cloudfunctions2_function" "function" {
27 | name = "${var.name}-notify-${local.slack_channel_sanitised}-${local.env}"
28 | description = "${var.name} Slack notification function to ${var.slack_channel} channel in ${local.env} environment"
29 | location = var.region
30 |
31 | build_config {
32 | runtime = var.runtime
33 | entry_point = "notify"
34 |
35 | source {
36 | storage_source {
37 | bucket = var.bucket_name
38 | object = google_storage_bucket_object.function.name
39 | }
40 | }
41 | }
42 |
43 | service_config {
44 | timeout_seconds = var.timeout
45 | environment_variables = {
46 | SLACK_CHANNEL = var.slack_channel
47 | SLACK_USERNAME = var.slack_username
48 | SLACK_EMOJI = var.slack_emoji
49 | }
50 | all_traffic_on_latest_revision = true
51 | service_account_email = var.service_account_email
52 | ingress_settings = var.ingress_settings
53 |
54 | secret_environment_variables {
55 | key = "SLACK_URL"
56 | project_id = var.project
57 | secret = local.secret_id
58 | version = local.secret_version
59 | }
60 | }
61 |
62 | event_trigger {
63 | trigger_region = var.region
64 | event_type = "google.cloud.pubsub.topic.v1.messagePublished"
65 | pubsub_topic = "projects/${var.project}/topics/${var.pubsub_topic}"
66 | service_account_email = var.service_account_eventarc
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/terraform-modules/function/main.tf:
--------------------------------------------------------------------------------
1 | # Dummy resource to ensure archive is created at apply stage
2 | resource "null_resource" "dummy_trigger" {
3 | triggers = {
4 | timestamp = timestamp()
5 | }
6 | }
7 |
8 | # zip source code
9 | data "archive_file" "code" {
10 | count = length(var.functions)
11 | type = "zip"
12 | source_dir = "${path.module}/code/${var.functions[count.index]}"
13 | output_path = "${path.module}/build/${var.functions[count.index]}.zip"
14 | depends_on = [
15 | # Make sure archive is created in apply stage
16 | null_resource.dummy_trigger
17 | ]
18 | }
19 |
20 | # upload compressed code to bucket
21 | resource "google_storage_bucket_object" "function" {
22 | count = length(var.functions)
23 | name = "${var.functions[count.index]}-${data.archive_file.code.*.output_md5[count.index]}.zip"
24 | bucket = var.bucket_name
25 | source = "${path.module}/build/${var.functions[count.index]}.zip"
26 | }
27 |
28 | resource "google_cloudfunctions2_function" "function" {
29 | count = length(var.functions)
30 | name = "${var.name}-${var.functions[count.index]}-${local.env}"
31 | location = var.region
32 | description = "${var.name} ${var.functions[count.index]} function in ${local.env} environment"
33 |
34 | build_config {
35 | runtime = var.runtime
36 | entry_point = var.functions[count.index]
37 |
38 | source {
39 | storage_source {
40 | bucket = var.bucket_name
41 | object = google_storage_bucket_object.function.*.name[count.index]
42 | }
43 | }
44 | }
45 |
46 | service_config {
47 | available_memory = "${var.available_memory}M"
48 | timeout_seconds = var.timeout
49 | environment_variables = {
50 | SECURITY_PROJECT = var.project
51 | APP_ENVIRONMENT = local.env
52 | APP_NAME = var.name
53 | }
54 | all_traffic_on_latest_revision = true
55 | service_account_email = var.service_account_email
56 | ingress_settings = var.ingress_settings
57 | }
58 |
59 | event_trigger {
60 | trigger_region = var.region
61 | event_type = "google.cloud.pubsub.topic.v1.messagePublished"
62 | pubsub_topic = "projects/${var.project}/topics/${var.pubsub_topic}"
63 | service_account_email = var.service_account_eventarc
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/manual-scans/utils_gcp.py:
--------------------------------------------------------------------------------
1 | from google.cloud.resourcemanager_v3 import (
2 | FoldersClient,
3 | OrganizationsClient,
4 | ProjectsClient,
5 | )
6 |
7 |
8 | def get_organization_id():
9 | # Gets organization ID - service account must only have rights to a single org
10 | # Requires Organization Viewer role at Organization level
11 | organizations_client = OrganizationsClient()
12 | orgs = organizations_client.search_organizations()
13 | for org in orgs:
14 | return org.name
15 |
16 | return ""
17 |
18 |
19 | def list_folders(parent_id):
20 | # Lists folders under a parent - requires Folder Viewer role at Organization level
21 | folders_client = FoldersClient()
22 | folders = folders_client.list_folders(parent=parent_id)
23 | folder_list = [f.name for f in folders]
24 |
25 | return folder_list
26 |
27 |
28 | def list_projects(parent_id):
29 | # Lists projects under a parent - requires Folder Viewer role at Organization level
30 | projects_client = ProjectsClient()
31 | projects = projects_client.list_projects(parent=parent_id)
32 | project_list = [p.project_id for p in projects if not p.project_id.startswith("sys-")]
33 |
34 | return project_list
35 |
36 |
37 | def list_all_projects():
38 | # Get organization ID
39 | org_id = get_organization_id()
40 |
41 | # Get all the project IDs at the organization level
42 | all_projects = list_projects(org_id)
43 |
44 | # Now retrieve all the folders directly under the organization
45 | folder_ids = list_folders(org_id)
46 |
47 | # Make sure that there are actually folders under the org
48 | if len(folder_ids) == 0:
49 | return all_projects
50 |
51 | # Start iterating over the folders
52 | while folder_ids:
53 | # Get the last folder of the list
54 | current_id = folder_ids.pop()
55 |
56 | # Get subfolders and add them to the list of folders
57 | subfolders = list_folders(current_id)
58 |
59 | if subfolders:
60 | folder_ids.extend(f for f in subfolders)
61 |
62 | # Get the projects under that folder
63 | projects_under_folder = list_projects(current_id)
64 |
65 | # Add projects if there are any
66 | if projects_under_folder:
67 | all_projects.extend(p for p in projects_under_folder)
68 |
69 | # Finally, return all the projects
70 | return all_projects
71 |
--------------------------------------------------------------------------------
/terraform-modules/function-slack/code/notify/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from __future__ import print_function
4 |
5 | import base64
6 | import json
7 | import os
8 | import requests
9 |
10 |
11 | def notify(event, context):
12 |
13 | slack_channel = os.environ["SLACK_CHANNEL"]
14 | slack_username = os.environ["SLACK_USERNAME"]
15 | slack_emoji = os.environ["SLACK_EMOJI"]
16 | slack_url = os.environ["SLACK_URL"]
17 |
18 | print(f"Function triggered by messageId {context.event_id} at {context.timestamp} to {context.resource['name']}")
19 |
20 | if "data" in event:
21 | pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
22 |
23 | # print(pubsub_message)
24 | json_data = json.loads(pubsub_message)
25 | findings = json_data["Findings"]
26 |
27 | payload = {
28 | "channel": slack_channel,
29 | "username": slack_username,
30 | "icon_emoji": slack_emoji,
31 | "attachments": [],
32 | "text": json_data["Subject"],
33 | }
34 |
35 | slack_message = {
36 | "fallback": "A new message",
37 | "fields": [{"title": "Vulnerable domains"}],
38 | }
39 |
40 | for finding in findings:
41 |
42 | try:
43 | cname = finding["CNAME"]
44 | print(f"VULNERABLE: {finding['Domain']} CNAME {cname} in GCP Project {finding['Project']}")
45 | slack_message["fields"].append(
46 | {
47 | "value": finding["Domain"] + " CNAME " + cname + " in GCP Project " + finding["Project"],
48 | "short": False,
49 | }
50 | )
51 |
52 | except KeyError:
53 | print(f"VULNERABLE: {finding['Domain']} in GCP Project {finding['Project']}")
54 | slack_message["fields"].append(
55 | {
56 | "value": finding["Domain"] + " in GCP Project " + finding["Project"],
57 | "short": False,
58 | }
59 | )
60 |
61 | payload["attachments"].append(slack_message)
62 | response = requests.post(
63 | slack_url,
64 | data=json.dumps(payload),
65 | headers={"Content-Type": "application/json"},
66 | timeout=10,
67 | )
68 | if response.status_code != 200:
69 | print(f"Request to Slack returned error {response.status_code}:\n{response.text}")
70 | else:
71 | print(f"Message sent to {slack_channel} Slack channel")
72 |
--------------------------------------------------------------------------------
/manual-scans/gcp-ns.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import dns.resolver
4 | import google.cloud.dns
5 | from utils_gcp import list_all_projects
6 | from utils_print import my_print, print_list
7 |
8 | start_time = datetime.now()
9 | vulnerable_domains = []
10 |
11 |
12 | def vulnerable_ns(domain_name):
13 |
14 | try:
15 | dns.resolver.resolve(domain_name)
16 |
17 | except dns.resolver.NXDOMAIN:
18 | return False
19 |
20 | except dns.resolver.NoNameservers:
21 |
22 | try:
23 | ns_records = dns.resolver.resolve(domain_name, "NS")
24 | if len(ns_records) == 0:
25 | return True
26 |
27 | except dns.resolver.NoNameservers:
28 | return True
29 |
30 | except dns.resolver.NoAnswer:
31 | return False
32 |
33 | return False
34 |
35 |
36 | def gcp(project):
37 |
38 | i = 0
39 |
40 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
41 | dns_client = google.cloud.dns.client.Client(project)
42 | managed_zones = dns_client.list_zones()
43 |
44 | try:
45 | managed_zones = dns_client.list_zones()
46 |
47 | for managed_zone in managed_zones:
48 | print(f"Searching for vulnerable NS records in {managed_zone.dns_name}")
49 |
50 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
51 |
52 | if dns_record_client.list_resource_record_sets():
53 |
54 | records = dns_record_client.list_resource_record_sets()
55 | resource_record_sets = [r for r in records if "NS" in r.record_type and r.name != managed_zone.dns_name]
56 |
57 | for resource_record_set in resource_record_sets:
58 | print(f"Testing {resource_record_set.name} for vulnerability")
59 | i = i + 1
60 | ns_record = resource_record_set.name
61 | result = vulnerable_ns(ns_record)
62 |
63 | if result:
64 | vulnerable_domains.append(ns_record)
65 | my_print(f"{str(i)}. {ns_record}", "ERROR")
66 | else:
67 | my_print(f"{str(i)}. {ns_record}", "SECURE")
68 |
69 | except google.api_core.exceptions.Forbidden:
70 | pass
71 |
72 |
73 | projects = list_all_projects()
74 | total_projects = len(projects)
75 | scanned_projects = 0
76 |
77 | for project in projects:
78 | gcp(project)
79 | scanned_projects = scanned_projects + 1
80 |
81 | scan_time = datetime.now() - start_time
82 | print(f"Scanned {str(scanned_projects)} of {str(total_projects)} projects in {scan_time.seconds} seconds")
83 |
84 | count = len(vulnerable_domains)
85 | my_print(f"\nTotal Vulnerable Domains Found: {str(count)}", "INFOB")
86 |
87 | if count > 0:
88 | my_print("List of Vulnerable Domains:", "INFOB")
89 | print_list(vulnerable_domains)
90 |
--------------------------------------------------------------------------------
/variables.tf:
--------------------------------------------------------------------------------
1 | variable "project" {
2 | description = "GCP project for domain protect infrastructure - define in tfvars file or CI/CD environment variables"
3 | default = ""
4 | }
5 |
6 | variable "name" {
7 | description = "application name, forms first part of resource names"
8 | default = "domain-protect"
9 | }
10 |
11 | variable "region" {
12 | description = "GCP region to deploy infrastructure"
13 | default = "europe-west2"
14 | }
15 |
16 | variable "app_service_region" {
17 | description = "GCP region which App Service is deployed to, this is a Project wide setting"
18 | default = "europe-west2"
19 | }
20 |
21 | variable "create_app_engine" {
22 | description = "Create App Engine if not already set up on the Project"
23 | default = false
24 | }
25 |
26 | variable "zone" {
27 | description = "GCP availability zone"
28 | default = "europe-west2-c"
29 | }
30 |
31 | variable "functions" {
32 | description = "list of names of Functions files in the function/code folder"
33 | default = ["cname", "ns", "cnamestorage", "astorage"] # names cannot include hyphens or underscores
34 | type = list(any)
35 | }
36 |
37 | variable "slack_channels" {
38 | description = "List of Slack Channels - enter in tfvars file or CI/CD environment variables"
39 | default = []
40 | type = list(any)
41 | }
42 |
43 | variable "slack_channels_dev" {
44 | description = "List of Slack Channels to use for testing purposes with dev environment - enter in tfvars file or CI/CD environment variables"
45 | default = []
46 | type = list(any)
47 | }
48 |
49 | variable "slack_webhook_urls" {
50 | description = "List of Slack webhook URLs, in the same order as the slack_channels list - enter in tfvars file or CI/CD environment variables"
51 | default = []
52 | type = list(any)
53 | }
54 |
55 | variable "slack_emoji" {
56 | description = "Slack emoji"
57 | default = ":warning:"
58 | }
59 |
60 | variable "slack_username" {
61 | description = "Slack username appearing in the from field in the Slack message"
62 | default = "Domain Protect"
63 | }
64 |
65 | variable "time_zone" {
66 | description = "Time zone used by Cloud Scheduler"
67 | default = "Europe/London"
68 | }
69 |
70 | variable "ingress_settings" {
71 | description = "Controls what traffic can reach the function"
72 | default = "ALLOW_INTERNAL_ONLY"
73 | }
74 |
75 | variable "schedule" {
76 | description = "Schedule for triggering functions in CRON syntax"
77 | default = "0 9 * * *" #fire at 9 a.m. every day
78 | }
79 |
80 | variable "schedule_dev" {
81 | description = "Schedule for triggering development functions in CRON syntax"
82 | default = "0 8 * * *" #fire at 8 a.m. every day
83 | }
84 |
85 | variable "available_memory" {
86 | description = "Available memory for function"
87 | default = 1024
88 | }
89 |
90 | variable "runtime" {
91 | description = "Lambda language runtime"
92 | default = "python311"
93 | }
94 |
95 | variable "timeout" {
96 | description = "Function timeout in seconds"
97 | default = 540
98 | }
--------------------------------------------------------------------------------
/terraform-modules/function-projects/code/projects/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import json
4 | import os
5 |
6 | import google.cloud.resourcemanager_v3
7 |
8 | from google.cloud import pubsub_v1
9 |
10 |
11 | def get_organization_id():
12 | # Gets organization ID - service account must only have rights to a single org
13 | # Requires Organization Viewer role at Organization level
14 | organizations_client = google.cloud.resourcemanager_v3.OrganizationsClient()
15 | orgs = organizations_client.search_organizations()
16 | for org in orgs:
17 | return org.name
18 |
19 | return ""
20 |
21 |
22 | def list_folders(parent_id):
23 | # Lists folders under a parent - requires Folder Viewer role at Organization level
24 | folders_client = google.cloud.resourcemanager_v3.FoldersClient()
25 | folders = folders_client.list_folders(parent=parent_id)
26 | folder_list = [f.name for f in folders]
27 |
28 | return folder_list
29 |
30 |
31 | def list_projects(parent_id):
32 | # Lists projects under a parent - requires Folder Viewer role at Organization level
33 | projects_client = google.cloud.resourcemanager_v3.ProjectsClient()
34 | projects = projects_client.list_projects(parent=parent_id)
35 | project_list = [p.project_id for p in projects if not p.project_id.startswith("sys-")]
36 |
37 | return project_list
38 |
39 |
40 | def projects(event, context): # pylint:disable=unused-argument
41 | security_project = os.environ["SECURITY_PROJECT"]
42 | app_name = os.environ["APP_NAME"]
43 | app_environment = os.environ["APP_ENVIRONMENT"]
44 |
45 | # Get organization ID
46 | org_id = get_organization_id()
47 |
48 | # Get all the project IDs at the organization level
49 | all_projects = list_projects(org_id)
50 |
51 | # Now retrieve all the folders directly under the organization
52 | folder_ids = list_folders(org_id)
53 |
54 | # Make sure that there are actually folders under the org
55 | if len(folder_ids) > 0:
56 |
57 | # Start iterating over the folders
58 | while folder_ids:
59 | # Get the last folder of the list
60 | current_id = folder_ids.pop()
61 |
62 | # Get subfolders and add them to the list of folders
63 | subfolders = list_folders(current_id)
64 |
65 | if subfolders:
66 | folder_ids.extend(f for f in subfolders)
67 |
68 | # Get the projects under that folder
69 | projects_under_folder = list_projects(current_id)
70 |
71 | # Add projects if there are any
72 | if projects_under_folder:
73 | all_projects.extend(p for p in projects_under_folder)
74 |
75 | print(f"Found {len(all_projects)} Projects in Organization")
76 |
77 | if len(all_projects) > 0:
78 | try:
79 | publisher = pubsub_v1.PublisherClient()
80 | topic_name = f"projects/{security_project}/topics/{app_name}-projects-{app_environment}"
81 | data = json.dumps({"Projects": all_projects})
82 | future = publisher.publish(topic_name, data=data.encode("utf-8"))
83 | print(f"Message ID {future.result()} published to topic {topic_name}")
84 |
85 | except google.api_core.exceptions.Forbidden:
86 | print(f"ERROR: Unable to publish to PubSub topic {topic_name}")
87 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/ns/main.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | import os
4 |
5 | import dns.resolver
6 | import google.cloud.dns
7 | from google.cloud import pubsub_v1
8 |
9 |
10 | def vulnerable_ns(domain_name):
11 |
12 | try:
13 | dns.resolver.resolve(domain_name)
14 |
15 | except dns.resolver.NXDOMAIN:
16 | return False
17 |
18 | except dns.resolver.NoNameservers:
19 |
20 | try:
21 | ns_records = dns.resolver.resolve(domain_name, "NS")
22 | if len(ns_records) == 0:
23 | return True
24 |
25 | except dns.resolver.NoNameservers:
26 | return True
27 |
28 | except dns.resolver.NoAnswer:
29 | return False
30 |
31 | return False
32 |
33 |
34 | def gcp(project):
35 |
36 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
37 | dns_client = google.cloud.dns.client.Client(project)
38 | try:
39 | managed_zones = dns_client.list_zones()
40 |
41 | for managed_zone in managed_zones:
42 | print(f"Searching for vulnerable NS records in {managed_zone.dns_name}")
43 |
44 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
45 |
46 | records = dns_record_client.list_resource_record_sets()
47 | resource_record_sets = [r for r in records if "NS" in r.record_type and r.name != managed_zone.dns_name]
48 |
49 | for resource_record_set in resource_record_sets:
50 | if resource_record_set.name != managed_zone.dns_name:
51 | print(f"Testing {resource_record_set.name}")
52 | ns_record = resource_record_set.name
53 | result = vulnerable_ns(ns_record)
54 |
55 | if result:
56 | print(f"VULNERABLE DOMAIN: {ns_record}")
57 | vulnerable_domains.append(ns_record)
58 | json_data["Findings"].append({"Project": project, "Domain": ns_record})
59 |
60 | except google.api_core.exceptions.Forbidden:
61 | pass
62 |
63 |
64 | def ns(event, context): # pylint:disable=unused-argument
65 |
66 | security_project = os.environ["SECURITY_PROJECT"]
67 | app_name = os.environ["APP_NAME"]
68 | app_environment = os.environ["APP_ENVIRONMENT"]
69 |
70 | global vulnerable_domains
71 | vulnerable_domains = []
72 | global json_data
73 | json_data = {"Findings": [], "Subject": "Vulnerable NS subdomain records found in Google Cloud DNS"}
74 |
75 | if "data" in event:
76 | pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
77 | projects_json = json.loads(pubsub_message)
78 | projects = projects_json["Projects"]
79 | scanned_projects = 0
80 | for project in projects:
81 | gcp(project)
82 | scanned_projects = scanned_projects + 1
83 |
84 | print(f"Scanned {str(scanned_projects)} of {str(len(projects))} projects")
85 |
86 | if len(vulnerable_domains) > 0:
87 | try:
88 | publisher = pubsub_v1.PublisherClient()
89 | topic_name = f"projects/{security_project}/topics/{app_name}-results-{app_environment}"
90 | encoded_data = json.dumps(json_data).encode("utf-8")
91 | future = publisher.publish(topic_name, data=encoded_data)
92 | print(f"Message ID {future.result()} published to topic {topic_name}")
93 |
94 | except google.api_core.exceptions.Forbidden:
95 | print(f"ERROR: Unable to publish to PubSub topic {topic_name}")
96 |
--------------------------------------------------------------------------------
/manual-scans/gcp-a-storage.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import google.cloud.dns
4 | import requests
5 | from utils_gcp import list_all_projects
6 | from utils_print import my_print, print_list
7 | from secrets import choice
8 | from string import ascii_letters, digits
9 |
10 | start_time = datetime.now()
11 | vulnerable_domains = []
12 | suspected_domains = []
13 | cname_values = []
14 |
15 |
16 | def vulnerable_storage(domain_name):
17 | # Handle wildcard A records by passing in a random 5 character string
18 | if domain_name[0] == "*":
19 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
20 | domain_name = random_string + domain_name[1:]
21 |
22 | try:
23 | response = requests.get("https://" + domain_name, timeout=1)
24 | if "NoSuchBucket" in response.text:
25 | return True
26 |
27 | except (
28 | requests.exceptions.SSLError,
29 | requests.exceptions.ConnectionError,
30 | requests.exceptions.ReadTimeout,
31 | ):
32 | pass
33 |
34 | try:
35 | response = requests.get("http://" + domain_name, timeout=1)
36 | if "NoSuchBucket" in response.text:
37 | return True
38 |
39 | except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
40 | pass
41 |
42 | return False
43 |
44 |
45 | def gcp(project):
46 | i = 0
47 |
48 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
49 | dns_client = google.cloud.dns.client.Client(project)
50 | try:
51 | managed_zones = dns_client.list_zones()
52 |
53 | for managed_zone in managed_zones:
54 | print(f"Searching for A records with missing storage buckets in {managed_zone.dns_name}")
55 |
56 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
57 |
58 | if dns_record_client.list_resource_record_sets():
59 |
60 | records = dns_record_client.list_resource_record_sets()
61 | resource_record_sets = [
62 | r
63 | for r in records
64 | if r.record_type in "A" and not any(ip_address.startswith("10.") for ip_address in r.rrdatas)
65 | ]
66 |
67 | for resource_record_set in resource_record_sets:
68 | a_record = resource_record_set.name
69 | print(f"Testing {a_record} for vulnerability")
70 | result = vulnerable_storage(a_record)
71 | i = i + 1
72 | if result:
73 | vulnerable_domains.append(a_record)
74 | my_print(f"{str(i)}. {a_record}", "ERROR")
75 | else:
76 | suspected_domains.append(a_record)
77 | my_print(f"{str(i)}. {a_record}", "SECURE")
78 |
79 | except google.api_core.exceptions.Forbidden:
80 | pass
81 |
82 |
83 | if __name__ == "__main__":
84 |
85 | projects = list_all_projects()
86 | total_projects = len(projects)
87 | scanned_projects = 0
88 |
89 | for project in projects:
90 | gcp(project)
91 | scanned_projects = scanned_projects + 1
92 |
93 | scan_time = datetime.now() - start_time
94 | print(f"Scanned {str(scanned_projects)} of {str(total_projects)} projects in {scan_time.seconds} seconds")
95 |
96 | count = len(vulnerable_domains)
97 | my_print("\nTotal Vulnerable Domains Found: " + str(count), "INFOB")
98 |
99 | if count > 0:
100 | my_print("List of Vulnerable Domains:", "INFOB")
101 | print_list(vulnerable_domains)
102 |
--------------------------------------------------------------------------------
/manual-scans/gcp-cname.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import dns.resolver
4 | import google.cloud.dns
5 | from utils_gcp import list_all_projects
6 | from utils_print import my_print, print_list
7 | from secrets import choice
8 | from string import ascii_letters, digits
9 |
10 | start_time = datetime.now()
11 | vulnerable_domains = []
12 | cname_values = []
13 | vulnerability_list = [
14 | "azure",
15 | ".cloudapp.net",
16 | "core.windows.net",
17 | "elasticbeanstalk.com",
18 | "trafficmanager.net",
19 | ]
20 |
21 |
22 | def vulnerable_cname(domain_name):
23 | # Handle wildcard A records by passing in a random 5 character string
24 | if domain_name[0] == "*":
25 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
26 | domain_name = random_string + domain_name[1:]
27 |
28 | global aRecords
29 |
30 | try:
31 | aRecords = dns.resolver.resolve(domain_name, "A")
32 | return False
33 |
34 | except dns.resolver.NXDOMAIN:
35 | try:
36 | dns.resolver.resolve(domain_name, "CNAME")
37 | return True
38 |
39 | except dns.resolver.NoNameservers:
40 | return False
41 |
42 | except (dns.resolver.NoAnswer, dns.resolver.NoNameservers):
43 | return False
44 |
45 |
46 | def gcp(project):
47 |
48 | i = 0
49 |
50 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
51 | dns_client = google.cloud.dns.client.Client(project)
52 | try:
53 | managed_zones = dns_client.list_zones()
54 |
55 | for managed_zone in managed_zones:
56 | print(f"Searching for vulnerable CNAME records in {managed_zone.dns_name}")
57 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
58 |
59 | if dns_record_client.list_resource_record_sets():
60 | records = dns_record_client.list_resource_record_sets()
61 | resource_record_sets = [
62 | r
63 | for r in records
64 | if "CNAME" in r.record_type
65 | and r.rrdatas
66 | and any(vulnerability in r.rrdatas[0] for vulnerability in vulnerability_list)
67 | ]
68 |
69 | for resource_record_set in resource_record_sets:
70 | cname_record = resource_record_set.name
71 | cname_value = resource_record_set.rrdatas[0]
72 | print(f"Testing {resource_record_set.name} for vulnerability")
73 | result = vulnerable_cname(cname_record)
74 | i = i + 1
75 | if result:
76 | vulnerable_domains.append(cname_record)
77 | cname_values.append(cname_value)
78 | my_print(f"{str(i)}.{cname_record} CNAME {cname_value}", "ERROR")
79 |
80 | else:
81 | my_print(f"{str(i)}.{cname_record} CNAME {cname_value}", "SECURE")
82 |
83 | except google.api_core.exceptions.Forbidden:
84 | pass
85 |
86 |
87 | projects = list_all_projects()
88 | total_projects = len(projects)
89 | scanned_projects = 0
90 |
91 | for project in projects:
92 | gcp(project)
93 | scanned_projects = scanned_projects + 1
94 |
95 | scan_time = datetime.now() - start_time
96 | print(f"Scanned {str(scanned_projects)} of {str(total_projects)} projects in {scan_time.seconds} seconds")
97 |
98 | count = len(vulnerable_domains)
99 | my_print("\nTotal Vulnerable Domains Found: " + str(count), "INFOB")
100 |
101 | if count > 0:
102 | my_print("List of Vulnerable Domains:", "INFOB")
103 | print_list(vulnerable_domains)
104 |
--------------------------------------------------------------------------------
/manual-scans/gcp-cname-storage.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | import google.cloud.dns
4 | import requests
5 | from utils_gcp import list_all_projects
6 | from utils_print import my_print, print_list
7 | from secrets import choice
8 | from string import ascii_letters, digits
9 |
10 | start_time = datetime.now()
11 | vulnerable_domains = []
12 | suspected_domains = []
13 | cname_values = []
14 | vulnerability_list = ["amazonaws.com", "cloudfront.net", "c.storage.googleapis.com"]
15 |
16 |
17 | def vulnerable_storage(domain_name):
18 | # Handle wildcard A records by passing in a random 5 character string
19 | if domain_name[0] == "*":
20 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
21 | domain_name = random_string + domain_name[1:]
22 |
23 | try:
24 | response = requests.get(f"http://{domain_name}", timeout=1)
25 | if "NoSuchBucket" in response.text:
26 | return True
27 |
28 | except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
29 | pass
30 |
31 | return False
32 |
33 |
34 | def gcp(project):
35 | i = 0
36 |
37 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
38 | dns_client = google.cloud.dns.client.Client(project)
39 | try:
40 | managed_zones = dns_client.list_zones()
41 |
42 | for managed_zone in managed_zones:
43 | print(f"Searching CNAMEs with missing storage buckets in {managed_zone.dns_name}")
44 |
45 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
46 |
47 | if dns_record_client.list_resource_record_sets():
48 |
49 | records = dns_record_client.list_resource_record_sets()
50 | resource_record_sets = [
51 | r
52 | for r in records
53 | if "CNAME" in r.record_type
54 | and r.rrdatas
55 | and any(vulnerability in r.rrdatas[0] for vulnerability in vulnerability_list)
56 | ]
57 | for resource_record_set in resource_record_sets:
58 | cname_record = resource_record_set.name
59 | cname_value = resource_record_set.rrdatas[0]
60 | print(f"Testing {resource_record_set.name} for vulnerability")
61 | result = vulnerable_storage(cname_record)
62 | i = i + 1
63 | if result:
64 | vulnerable_domains.append(cname_record)
65 | cname_values.append(cname_value)
66 | my_print(
67 | f"{str(i)}. {cname_record} CNAME {cname_value}",
68 | "ERROR",
69 | )
70 | elif not result:
71 | suspected_domains.append(cname_record)
72 | my_print(
73 | f"{str(i)}. {cname_record} CNAME {cname_value}",
74 | "SECURE",
75 | )
76 | else:
77 | my_print("WARNING: no response from test", "INFOB")
78 |
79 | except google.api_core.exceptions.Forbidden:
80 | pass
81 |
82 |
83 | if __name__ == "__main__":
84 |
85 | projects = list_all_projects()
86 | total_projects = len(projects)
87 | scanned_projects = 0
88 |
89 | for project in projects:
90 | gcp(project)
91 | scanned_projects = scanned_projects + 1
92 |
93 | scan_time = datetime.now() - start_time
94 | print(f"Scanned {str(scanned_projects)} of {str(total_projects)} projects in {scan_time.seconds} seconds")
95 |
96 | count = len(vulnerable_domains)
97 | my_print("\nTotal Vulnerable Domains Found: " + str(count), "INFOB")
98 |
99 | if count > 0:
100 | my_print("List of Vulnerable Domains:", "INFOB")
101 | print_list(vulnerable_domains)
102 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 |
5 | jobs:
6 | python_tests:
7 | name: Python tests
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: checkout
11 | uses: actions/checkout@v4
12 |
13 | - name: Set up Python 3.11
14 | uses: actions/setup-python@v5
15 | with:
16 | python-version: '3.11'
17 |
18 | - name: Display Python version
19 | run: python -c "import sys; print(sys.version)"
20 |
21 | - name: Install dependencies
22 | run: |
23 | pip install -r requirements-dev.txt
24 |
25 | - name: Black
26 | run: |
27 | black --check --line-length 120 .
28 |
29 | - name: Prospector
30 | run: |
31 | prospector
32 |
33 | - name: install bandit
34 | run: |
35 | pip3 install --upgrade bandit
36 | echo $PATH
37 | bandit --version
38 | which bandit
39 |
40 | - name: prepare reports dir
41 | run: mkdir --parents ${{runner.temp}}/reports_sast_python/
42 |
43 | - name: generate json report
44 | run: >
45 | bandit -r
46 | --exit-zero
47 | --ini .config/sast_python_bandit_json.yml .
48 | 1> ${{runner.temp}}/reports_sast_python/${RANDOM}.json
49 |
50 | - name: save json report
51 | uses: actions/upload-artifact@v4
52 | with:
53 | name: sast_python
54 | if-no-files-found: error
55 | path: ${{runner.temp}}/reports_sast_python/
56 |
57 | - name: test code
58 | run: >
59 | bandit
60 | --ini .config/sast_python_bandit_cli.yml .
61 |
62 | terraform_tests:
63 | name: Terraform tests
64 | runs-on: ubuntu-latest
65 | permissions:
66 | id-token: write
67 | contents: write
68 | pull-requests: write
69 | checks: write
70 | steps:
71 | - name: Terraform setup
72 | uses: hashicorp/setup-terraform@v3
73 | with:
74 | terraform_version: 1.4.6
75 |
76 | - name: checkout
77 | uses: actions/checkout@v4
78 |
79 | - name: Terraform validate
80 | id: fmt
81 | run: terraform fmt -check -recursive
82 |
83 | - name: install checkov
84 | run: |
85 | pip3 install --upgrade checkov
86 | echo $PATH
87 | checkov --version
88 | which checkov
89 |
90 | - name: prepare reports dir
91 | run: mkdir --parents ${{runner.temp}}/reports_sast_terraform/
92 |
93 | - name: generate json report
94 | run: >
95 | checkov
96 | --config-file .config/sast_terraform_checkov_json.yml
97 | --directory .
98 | 1> ${{runner.temp}}/reports_sast_terraform/${RANDOM}.json
99 |
100 | - name: save json report
101 | uses: actions/upload-artifact@v4
102 | with:
103 | name: sast_terraform
104 | if-no-files-found: error
105 | path: ${{runner.temp}}/reports_sast_terraform/
106 |
107 | - name: test code
108 | run: >
109 | checkov
110 | --config-file .config/sast_terraform_checkov_cli.yml
111 | --directory .
112 |
113 | CodeQL-Build:
114 | runs-on: ubuntu-latest
115 | permissions:
116 | actions: read
117 | contents: read
118 | security-events: write
119 |
120 | strategy:
121 | fail-fast: false
122 | matrix:
123 | language: [ 'python' ]
124 |
125 | steps:
126 | - name: Checkout repository
127 | uses: actions/checkout@v4
128 |
129 | - name: Initialize CodeQL
130 | uses: github/codeql-action/init@v3
131 | with:
132 | languages: ${{ matrix.language }}
133 |
134 | - name: Autobuild
135 | uses: github/codeql-action/autobuild@v3
136 |
137 | - name: Perform CodeQL Analysis
138 | uses: github/codeql-action/analyze@v3
139 | with:
140 | category: "/language:${{matrix.language}}"
141 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/astorage/main.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | import os
4 |
5 | import google.cloud.dns
6 | import requests
7 | from google.cloud import pubsub_v1
8 | from secrets import choice
9 | from string import ascii_letters, digits
10 |
11 |
12 | def vulnerable_storage(domain_name):
13 | # Handle wildcard A records by passing in a random 5 character string
14 | if domain_name[0] == "*":
15 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
16 | domain_name = random_string + domain_name[1:]
17 |
18 | try:
19 | response = requests.get("https://" + domain_name, timeout=0.5)
20 | if "NoSuchBucket" in response.text:
21 | return True
22 |
23 | except (
24 | requests.exceptions.SSLError,
25 | requests.exceptions.ConnectionError,
26 | requests.exceptions.ReadTimeout,
27 | ):
28 | pass
29 |
30 | try:
31 | response = requests.get("http://" + domain_name, timeout=0.2)
32 | if "NoSuchBucket" in response.text:
33 | return True
34 |
35 | except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
36 | pass
37 |
38 | return False
39 |
40 |
41 | def gcp(project):
42 |
43 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
44 | dns_client = google.cloud.dns.client.Client(project)
45 | try:
46 | managed_zones = dns_client.list_zones()
47 |
48 | for managed_zone in managed_zones:
49 | print(f"Searching for vulnerable A records in {managed_zone.dns_name}")
50 |
51 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
52 |
53 | if dns_record_client.list_resource_record_sets():
54 | records = dns_record_client.list_resource_record_sets()
55 | resource_record_sets = [
56 | r
57 | for r in records
58 | if r.record_type in "A" and not any(ip_address.startswith("10.") for ip_address in r.rrdatas)
59 | ]
60 |
61 | for resource_record_set in resource_record_sets:
62 | a_record = resource_record_set.name
63 | print(f"Testing {resource_record_set.name} for vulnerability")
64 | result = vulnerable_storage(a_record)
65 | if result:
66 | print(f"VULNERABLE: {a_record} in GCP project {project}")
67 | vulnerable_domains.append(a_record)
68 | json_data["Findings"].append({"Project": project, "Domain": a_record})
69 |
70 | except google.api_core.exceptions.Forbidden:
71 | pass
72 |
73 |
74 | def astorage(event, context): # pylint:disable=unused-argument
75 |
76 | security_project = os.environ["SECURITY_PROJECT"]
77 | app_name = os.environ["APP_NAME"]
78 | app_environment = os.environ["APP_ENVIRONMENT"]
79 |
80 | global vulnerable_domains
81 | vulnerable_domains = []
82 | global json_data
83 | json_data = {
84 | "Findings": [],
85 | "Subject": "Vulnerable A record in Google Cloud DNS - missing storage bucket",
86 | }
87 |
88 | if "data" in event:
89 | pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
90 | projects_json = json.loads(pubsub_message)
91 | projects = projects_json["Projects"]
92 | scanned_projects = 0
93 | for project in projects:
94 | gcp(project)
95 | scanned_projects = scanned_projects + 1
96 |
97 | print(f"Scanned {str(scanned_projects)} of {str(len(projects))} projects")
98 |
99 | if len(vulnerable_domains) > 0:
100 | try:
101 | publisher = pubsub_v1.PublisherClient()
102 | topic_name = f"projects/{security_project}/topics/{app_name}-results-{app_environment}"
103 | encoded_data = json.dumps(json_data).encode("utf-8")
104 | future = publisher.publish(topic_name, data=encoded_data)
105 | print(f"Message ID {future.result()} published to topic {topic_name}")
106 |
107 | except google.api_core.exceptions.Forbidden:
108 | print(f"ERROR: Unable to publish to PubSub topic {topic_name}")
109 |
--------------------------------------------------------------------------------
/terraform-modules/function/code/cnamestorage/main.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | import os
4 |
5 | import google.cloud.dns
6 | import requests
7 | from google.cloud import pubsub_v1
8 | from secrets import choice
9 | from string import ascii_letters, digits
10 |
11 |
12 | def vulnerable_storage(domain_name):
13 | # Handle wildcard A records by passing in a random 5 character string
14 | if domain_name[0] == "*":
15 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
16 | domain_name = random_string + domain_name[1:]
17 |
18 | try:
19 | response = requests.get(f"http://{domain_name}", timeout=1)
20 | if "NoSuchBucket" in response.text:
21 | return True
22 |
23 | except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
24 | pass
25 |
26 | return False
27 |
28 |
29 | def gcp(project):
30 |
31 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
32 | dns_client = google.cloud.dns.client.Client(project)
33 | try:
34 | managed_zones = dns_client.list_zones()
35 |
36 | for managed_zone in managed_zones:
37 | print(f"Searching for CNAMEs with missing storage buckets in {managed_zone.dns_name}")
38 |
39 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
40 |
41 | if dns_record_client.list_resource_record_sets():
42 | records = dns_record_client.list_resource_record_sets()
43 | resource_record_sets = [
44 | r
45 | for r in records
46 | if "CNAME" in r.record_type
47 | and r.rrdatas
48 | and any(vulnerability in r.rrdatas[0] for vulnerability in vulnerability_list)
49 | ]
50 | for resource_record_set in resource_record_sets:
51 | cname_record = resource_record_set.name
52 | cname_value = resource_record_set.rrdatas[0]
53 | print(f"Testing {resource_record_set.name} for vulnerability")
54 | result = vulnerable_storage(cname_record)
55 | if result:
56 | print(f"VULNERABLE: {cname_record} CNAME {cname_value} in GCP project {project}")
57 | vulnerable_domains.append(cname_record)
58 | json_data["Findings"].append(
59 | {
60 | "Project": project,
61 | "Domain": cname_record,
62 | "CNAME": cname_value,
63 | }
64 | )
65 |
66 | except google.api_core.exceptions.Forbidden:
67 | pass
68 |
69 |
70 | def cnamestorage(event, context): # pylint:disable=unused-argument
71 |
72 | security_project = os.environ["SECURITY_PROJECT"]
73 | app_name = os.environ["APP_NAME"]
74 | app_environment = os.environ["APP_ENVIRONMENT"]
75 |
76 | global vulnerability_list
77 | vulnerability_list = ["amazonaws.com", "cloudfront.net", "c.storage.googleapis.com"]
78 | global vulnerable_domains
79 | vulnerable_domains = []
80 | global json_data
81 | json_data = {
82 | "Findings": [],
83 | "Subject": "Vulnerable CNAME records in Google Cloud DNS",
84 | }
85 |
86 | if "data" in event:
87 | pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
88 | projects_json = json.loads(pubsub_message)
89 | projects = projects_json["Projects"]
90 | scanned_projects = 0
91 | for project in projects:
92 | gcp(project)
93 | scanned_projects = scanned_projects + 1
94 |
95 | print(f"Scanned {str(scanned_projects)} of {str(len(projects))} projects")
96 |
97 | if len(vulnerable_domains) > 0:
98 | try:
99 | publisher = pubsub_v1.PublisherClient()
100 | topic_name = f"projects/{security_project}/topics/{app_name}-results-{app_environment}"
101 | encoded_data = json.dumps(json_data).encode("utf-8")
102 | future = publisher.publish(topic_name, data=encoded_data)
103 | print(f"Message ID {future.result()} published to topic {topic_name}")
104 |
105 | except google.api_core.exceptions.Forbidden:
106 | print(f"ERROR: Unable to publish to PubSub topic {topic_name}")
107 |
--------------------------------------------------------------------------------
/main.tf:
--------------------------------------------------------------------------------
1 | module "services" {
2 | source = "./terraform-modules/services"
3 | }
4 |
5 | module "iam" {
6 | source = "./terraform-modules/iam"
7 | name = var.name
8 | project = var.project
9 | depends_on = [module.services.iam_service_id]
10 | }
11 |
12 | module "iam-eventarc" {
13 | source = "./terraform-modules/iam-eventarc"
14 | name = var.name
15 | project = var.project
16 | depends_on = [module.services.event_arc_service_id]
17 | }
18 |
19 | module "pubsub-projects" {
20 | source = "./terraform-modules/pubsub-projects"
21 | name = var.name
22 | project = var.project
23 | }
24 |
25 | module "pubsub-results" {
26 | source = "./terraform-modules/pubsub-results"
27 | name = var.name
28 | project = var.project
29 | }
30 |
31 | module "pubsub-scheduler" {
32 | source = "./terraform-modules/pubsub-scheduler"
33 | name = var.name
34 | app_service_region = var.app_service_region
35 | create_app_engine = var.create_app_engine
36 | project = var.project
37 | time_zone = var.time_zone
38 | schedule = var.schedule
39 | schedule_dev = var.schedule_dev
40 | depends_on = [module.services.cloud_scheduler_service_id]
41 | }
42 |
43 | module "storage" {
44 | source = "./terraform-modules/storage"
45 | name = var.name
46 | region = var.region
47 | }
48 |
49 | module "function-projects" {
50 | source = "./terraform-modules/function-projects"
51 | name = var.name
52 | project = var.project
53 | region = var.region
54 | bucket_name = module.storage.bucket_name
55 | timeout = var.timeout
56 | ingress_settings = var.ingress_settings
57 | runtime = var.runtime
58 | pubsub_topic = module.pubsub-scheduler.pubsub_topic_name
59 | service_account_email = module.iam.service_account_email
60 | service_account_eventarc = module.iam-eventarc.service_account_email
61 | depends_on = [module.services.cloud_functions_service_id, module.services.cloud_build_service_id, module.services.cloud_run_service_id]
62 | }
63 |
64 | module "function" {
65 | source = "./terraform-modules/function"
66 | functions = var.functions
67 | name = var.name
68 | project = var.project
69 | region = var.region
70 | bucket_name = module.storage.bucket_name
71 | available_memory = var.available_memory
72 | timeout = var.timeout
73 | ingress_settings = var.ingress_settings
74 | runtime = var.runtime
75 | pubsub_topic = module.pubsub-projects.pubsub_topic_name
76 | service_account_email = module.iam.service_account_email
77 | service_account_eventarc = module.iam-eventarc.service_account_email
78 | depends_on = [module.services.cloud_functions_service_id, module.services.cloud_build_service_id, module.services.cloud_run_service_id, module.services.event_arc_service_id]
79 | }
80 |
81 | module "function-slack" {
82 | source = "./terraform-modules/function-slack"
83 | for_each = toset(local.slack_channels)
84 | name = var.name
85 | project = var.project
86 | region = var.region
87 | bucket_name = module.storage.bucket_name
88 | timeout = var.timeout
89 | ingress_settings = var.ingress_settings
90 | runtime = var.runtime
91 | pubsub_topic = module.pubsub-results.pubsub_topic_name
92 | secret_resource_id = module.secret-manager[each.key].secret_resource_id
93 | secret_version_name = module.secret-manager[each.key].secret_version_name
94 | service_account_email = module.iam.service_account_email
95 | service_account_eventarc = module.iam-eventarc.service_account_email
96 | slack_channel = each.key
97 | slack_emoji = var.slack_emoji
98 | slack_username = var.slack_username
99 | depends_on = [module.services.cloud_functions_service_id, module.services.cloud_build_service_id, module.services.cloud_run_service_id]
100 | }
101 |
102 | module "secret-manager" {
103 | source = "./terraform-modules/secret-manager"
104 | for_each = local.secrets
105 | region = var.region
106 | app_name = var.name
107 | secret_name = each.key
108 | secret_value = each.value
109 | depends_on = [module.services.secret_manager_service_id]
110 | }
--------------------------------------------------------------------------------
/terraform-modules/function/code/cname/main.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | import os
4 |
5 | import dns.resolver
6 | import google.cloud.dns
7 | from google.cloud import pubsub_v1
8 | from secrets import choice
9 | from string import ascii_letters, digits
10 |
11 |
12 | def vulnerable_cname(domain_name):
13 | # Handle wildcard A records by passing in a random 5 character string
14 | if domain_name[0] == "*":
15 | random_string = "".join(choice(ascii_letters + digits) for _ in range(5))
16 | domain_name = random_string + domain_name[1:]
17 |
18 | global aRecords
19 |
20 | try:
21 | aRecords = dns.resolver.resolve(domain_name, "A")
22 | return False
23 |
24 | except dns.resolver.NXDOMAIN:
25 | try:
26 | dns.resolver.resolve(domain_name, "CNAME")
27 | return True
28 |
29 | except dns.resolver.NoNameservers:
30 | return False
31 |
32 | except (dns.resolver.NoAnswer, dns.resolver.NoNameservers):
33 | return False
34 |
35 |
36 | def gcp(project):
37 |
38 | print(f"Searching for Google Cloud DNS hosted zones in {project} project")
39 | dns_client = google.cloud.dns.client.Client(project)
40 | try:
41 | managed_zones = dns_client.list_zones()
42 |
43 | for managed_zone in managed_zones:
44 | print(f"Searching for vulnerable CNAME records in {managed_zone.dns_name}")
45 |
46 | dns_record_client = google.cloud.dns.zone.ManagedZone(name=managed_zone.name, client=dns_client)
47 |
48 | if dns_record_client.list_resource_record_sets():
49 | records = dns_record_client.list_resource_record_sets()
50 | resource_record_sets = [
51 | r
52 | for r in records
53 | if "CNAME" in r.record_type
54 | and r.rrdatas
55 | and any(vulnerability in r.rrdatas[0] for vulnerability in vulnerability_list)
56 | ]
57 |
58 | for resource_record_set in resource_record_sets:
59 | cname_record = resource_record_set.name
60 | cname_value = resource_record_set.rrdatas[0]
61 | print(f"Testing {resource_record_set.name} for vulnerability")
62 | result = vulnerable_cname(cname_record)
63 | if result:
64 | print(f"VULNERABLE: {cname_record} CNAME {cname_value} in GCP project {project}")
65 | vulnerable_domains.append(cname_record)
66 | json_data["Findings"].append(
67 | {
68 | "Project": project,
69 | "Domain": cname_record,
70 | "CNAME": cname_value,
71 | }
72 | )
73 |
74 | except google.api_core.exceptions.Forbidden:
75 | pass
76 |
77 |
78 | def cname(event, context): # pylint:disable=unused-argument
79 |
80 | security_project = os.environ["SECURITY_PROJECT"]
81 | app_name = os.environ["APP_NAME"]
82 | app_environment = os.environ["APP_ENVIRONMENT"]
83 |
84 | global vulnerability_list
85 | vulnerability_list = [
86 | "azure",
87 | ".cloudapp.net",
88 | "core.windows.net",
89 | "elasticbeanstalk.com",
90 | "trafficmanager.net",
91 | ]
92 | global vulnerable_domains
93 | vulnerable_domains = []
94 | global json_data
95 | json_data = {
96 | "Findings": [],
97 | "Subject": "Vulnerable CNAME records in Google Cloud DNS",
98 | }
99 |
100 | if "data" in event:
101 | pubsub_message = base64.b64decode(event["data"]).decode("utf-8")
102 | projects_json = json.loads(pubsub_message)
103 | projects = projects_json["Projects"]
104 | scanned_projects = 0
105 | for project in projects:
106 | gcp(project)
107 | scanned_projects = scanned_projects + 1
108 |
109 | print(f"Scanned {str(scanned_projects)} of {str(len(projects))} projects")
110 |
111 | if len(vulnerable_domains) > 0:
112 | try:
113 | publisher = pubsub_v1.PublisherClient()
114 | topic_name = f"projects/{security_project}/topics/{app_name}-results-{app_environment}"
115 | encoded_data = json.dumps(json_data).encode("utf-8")
116 | future = publisher.publish(topic_name, data=encoded_data)
117 | print(f"Message ID {future.result()} published to topic {topic_name}")
118 |
119 | except google.api_core.exceptions.Forbidden:
120 | print(f"ERROR: Unable to publish to PubSub topic {topic_name}")
121 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # domain-protect-gcp
2 | * Scans Google Cloud DNS across a GCP Organization for domain records vulnerable to takeover
3 | * Amazon Route53 vulnerable domains can be detected by [Domain Protect](https://github.com/ovotech/domain-protect)
4 |
5 | ### deploy to security audit project and scan your entire GCP Organization
6 |
7 | 
8 |
9 | ### receive alerts by Slack or email
10 |
11 |
12 |
13 |
14 |
15 | ### deploy in your GCP Organization using GitHub Actions
16 |
17 |
18 |
19 |
20 |
21 | ### or manually scan from your laptop
22 |
23 | 
24 |
25 | ## subdomain detection functionality
26 | Scans Google Cloud DNS for:
27 | * Subdomain NS delegations vulnerable to takeover
28 | * CNAME records for missing Google Cloud Storage buckets
29 | * A records for Google Cloud Load Balancer with missing storage bucket backend
30 | * Vulnerable CNAME records for Azure resources
31 | * Vulnerable CNAME records for AWS resources
32 | * CNAME for Amazon CloudFront distributions with missing S3 origin
33 | * CNAME for Amazon S3 website
34 |
35 | ## options
36 | 1. scheduled Google Cloud Functions with Slack alerts, across a GCP Organization, deployed using Terraform
37 | 2. [manual scans](manual-scans/README.md) run from your laptop or Cloud Shell
38 |
39 | ## notifications
40 | * Slack channel notification per vulnerability type, listing account names and vulnerable domains
41 |
42 | ## requirements
43 | * Storage bucket for Terraform state file
44 | * Terraform 1.0.x
45 | * Service Usage API enabled on Google Cloud project
46 |
47 | ## deployment permissions
48 | The Terraform service account requires the following roles at the Project level:
49 | ```
50 | App Engine Creator
51 | Cloud Functions Developer
52 | Cloud Scheduler Admin
53 | Create Service Accounts
54 | Project IAM Admin
55 | Pub/Sub Admin
56 | Secret Manager Admin
57 | Service Account Admin
58 | Service Account User
59 | Service Usage Admin
60 | Storage Admin
61 | ```
62 |
63 | ## usage
64 | * replace the Terraform state Google Cloud Storage bucket fields in the command below as appropriate
65 | * for local testing, duplicate terraform.tfvars.example, rename without the .example suffix
66 | * enter details appropriate to your organization and save
67 | * alternatively enter Terraform variables within your CI/CD pipeline
68 | * check whether App Engine has been created in the infrastructure project
69 | * add Terraform variables ```create_app_engine``` and ```app_service_region``` if different from default
70 |
71 | ```
72 | terraform init -backend-config=bucket=TERRAFORM_STATE_BUCKET -backend-config=prefix="terraform/state/domain-protect-gcp"
73 | terraform workspace new dev
74 | terraform plan
75 | terraform apply
76 | ```
77 |
78 | ## manually apply audit permissions at org level
79 | * At the organisation level, IAM, apply the following permissions to the domain-protect service account:
80 | ```
81 | DNS Reader (roles/dns.reader)
82 | Folder Viewer (roles/resourcemanager.folderViewer)
83 | Organization Viewer (roles/resourcemanager.organizationViewer)
84 | ```
85 | * This step is performed manually to avoid giving org wide IAM permisssions to the Terraform service account
86 |
87 | ## ensure correct permissions on Google service accounts
88 | * Functions v2 requires an additional permission on two Google managed service accounts
89 | * These are GCP Project level settings
90 | * Role required: `roles/iam.serviceAccountTokenCreator`
91 | * Google managed service accounts:
92 | ```
93 | service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com
94 | service-${PROJECT_NUMBER}@gcf-admin-robot.iam.gserviceaccount.com
95 | ```
96 | * They may need to be added using the console or `gcloud`
97 | * In GCP console for the security project select IAM
98 |
99 | 
100 |
101 | * tick the box `Include Google provided role grants`
102 | * select the Google Pub/Sub service account
103 |
104 | 
105 |
106 | * if it's not present, that means you need to grant access
107 | * select the Google functions robot service account
108 |
109 | 
110 |
111 | * if required, add the Service Account Token Creator role to both service accounts
112 | * this can be done via the GCP console or using gcloud:
113 | ```
114 | export PROJECT_NUMBER="$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)')"
115 |
116 | gcloud projects add-iam-policy-binding $(gcloud config get-value project) \
117 | --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
118 | --role='roles/iam.serviceAccountTokenCreator'
119 |
120 | gcloud projects add-iam-policy-binding $(gcloud config get-value project) \
121 | --member="serviceAccount:service-${PROJECT_NUMBER}@gcf-admin-robot.iam.gserviceaccount.com" \
122 | --role='roles/iam.serviceAccountTokenCreator'
123 | ```
124 |
125 | ## adding notifications to extra Slack channels
126 | * add an extra channel to your slack_channels variable list
127 | * add an extra webhook URL or repeat the same webhook URL to your slack_webhook_urls variable list
128 | * apply Terraform
129 |
130 | ## ci/cd
131 | * infrastructure deployed using GitHub Actions
132 | * use separate deployment repository [domain-protect-gcp-deploy](https://github.com/domain-protect/domain-protect-gcp-deploy)
133 | * use OpenID Connect, service account keys not required
134 | * configuration details provided at [domain-protect-gcp-deploy](https://github.com/domain-protect/domain-protect-gcp-deploy)
135 |
136 | | GITHUB ACTIONS SECRETS | EXAMPLE |
137 | |--------------------------------|--------------------------------------------------------------------------------------------------------------|
138 | | PROJECT | mygcpprojectid |
139 | | APP_SERVICE_REGION | europe-west2 |
140 | | GCP_WORKLOAD_IDENTITY_PROVIDER | projects/123456789/locations/global/workloadIdentityPools/github-actions/providers/domain-protect-gcp-github |
141 | | GCP_SERVICE_ACCOUNT | my-service-account@my-project.iam.gserviceaccount.com |
142 | | TERRAFORM_STATE_BUCKET | tfstate48903 |
143 | | TERRAFORM_STATE_PREFIX | terraform/state/domain-protect-gcp | | |
144 | | SLACK_CHANNELS | ["security-alerts"] |
145 | | SLACK_CHANNELS_DEV | ["security-alerts-dev"] |
146 | | SLACK_WEBHOOK_URLS | ["https://hooks.slack.com/services/XXX/XXX/XXX"] |
147 |
148 |
149 | ## local development
150 |
151 | * Python and Terraform local tests:
152 | ```
153 | black --check --line-length 120 .
154 | prospector --max-line-length 120 --profile tests/prospector/profile.yaml
155 | bandit --ini .config/sast_python_bandit_cli.yml manual-scans terraform-modules
156 | terraform fmt -check -recursive
157 | checkov --config-file .config/sast_terraform_checkov_cli.yml --directory
158 | ```
159 |
160 | ## limitations
161 | * this tool cannot guarantee 100% protection against subdomain takeover
162 | * it only scans Google Cloud DNS, and only checks a limited number of takeover types
163 | * for detection of Amazon Route53 vulnerable domains use [Domain Protect](https://github.com/ovotech/domain-protect)
--------------------------------------------------------------------------------