├── .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 | ![Alt text](images/gcp-ns.png?raw=true "Detect vulnerable subdomains") 44 | 45 | ## usage - vulnerable CNAMEs 46 | ``` 47 | python gcp-cname.py 48 | ``` 49 | 50 | ![Alt text](images/gcp-cname.png?raw=true "Detect vulnerable subdomains") 51 | 52 | ## usage - CNAMEs for missing storage buckets 53 | ``` 54 | python gcp-cname-storage.py 55 | ``` 56 | 57 | ![Alt text](images/gcp-cname-storage.png?raw=true "Detect vulnerable subdomains") 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 | ![Alt text](images/gcp-a-storage.png?raw=true "Detect vulnerable subdomains") 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 | ![Alt text](images/gcp-architecture.png?raw=true "Domain Protect GCP architecture") 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 | ![Alt text](manual-scans/images/gcp-cname.png?raw=true "Detect vulnerable ElasticBeanstalk CNAMEs") 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 | ![Alt text](images/google-services.png?raw=true "Show Google services") 100 | 101 | * tick the box `Include Google provided role grants` 102 | * select the Google Pub/Sub service account 103 | 104 | ![Alt text](images/pubsub-permissions.png?raw=true "Pub/Sub permissions") 105 | 106 | * if it's not present, that means you need to grant access 107 | * select the Google functions robot service account 108 | 109 | ![Alt text](images/robot-permissions.png?raw=true "GCF robot permissions") 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) --------------------------------------------------------------------------------