diff --git a/ghostwriter/commandcenter/admin.py b/ghostwriter/commandcenter/admin.py index 3e1a9d40c..2787eb8f6 100644 --- a/ghostwriter/commandcenter/admin.py +++ b/ghostwriter/commandcenter/admin.py @@ -13,6 +13,7 @@ ExtraFieldSpec, GeneralConfiguration, NamecheapConfiguration, + CloudflareConfiguration, ReportConfiguration, SlackConfiguration, VirusTotalConfiguration, @@ -23,6 +24,7 @@ admin.site.register(CloudServicesConfiguration, SingletonModelAdmin) admin.site.register(CompanyInformation, SingletonModelAdmin) admin.site.register(NamecheapConfiguration, SingletonModelAdmin) +admin.site.register(CloudflareConfiguration, SingletonModelAdmin) admin.site.register(SlackConfiguration, SingletonModelAdmin) admin.site.register(VirusTotalConfiguration, SingletonModelAdmin) admin.site.register(GeneralConfiguration, SingletonModelAdmin) diff --git a/ghostwriter/commandcenter/migrations/0001_initial.py b/ghostwriter/commandcenter/migrations/0001_initial.py index 0d59ec854..855317bdc 100644 --- a/ghostwriter/commandcenter/migrations/0001_initial.py +++ b/ghostwriter/commandcenter/migrations/0001_initial.py @@ -90,6 +90,21 @@ class Migration(migrations.Migration): "verbose_name": "Namecheap Configuration", }, ), + migrations.CreateModel( + name="CloudflareConfiguration", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("enable", models.BooleanField(default=False)), + ("api_key", models.CharField(default="Cloudflare Global API Key", max_length=255)), + ("username", models.CharField(default="Account email", max_length=255)), + ("account_id", models.CharField(default="Account ID", max_length=255)), + ("client_ip", models.CharField(default="Whitelisted IP Address", max_length=255)), + ("page_size", models.IntegerField(default=100)), + ], + options={ + "verbose_name": "Cloudflare Configuration", + }, + ), migrations.CreateModel( name="ReportConfiguration", fields=[ diff --git a/ghostwriter/commandcenter/migrations/0034_add_gcp_fields.py b/ghostwriter/commandcenter/migrations/0034_add_gcp_fields.py new file mode 100644 index 000000000..086aa72c6 --- /dev/null +++ b/ghostwriter/commandcenter/migrations/0034_add_gcp_fields.py @@ -0,0 +1,92 @@ +# Generated manually to add GCP credential fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("commandcenter", "0033_auto_20241204_1810"), # Replace with your actual previous migration + ] + + operations = [ + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_project_id", + field=models.CharField( + max_length=255, + default="your-project-id", + verbose_name="GCP Project ID", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_private_key_id", + field=models.CharField( + max_length=255, + default="your-private-key-id", + verbose_name="GCP Private Key ID", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_private_key", + field=models.TextField( + default="your-private-key", + verbose_name="GCP Private Key", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_client_email", + field=models.CharField( + max_length=255, + default="your-service-account@project.iam.gserviceaccount.com", + verbose_name="GCP Client Email", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_client_id", + field=models.CharField( + max_length=255, + default="your-client-id", + verbose_name="GCP Client ID", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_auth_uri", + field=models.CharField( + max_length=255, + default="https://accounts.google.com/o/oauth2/auth", + verbose_name="GCP Auth URI", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_token_uri", + field=models.CharField( + max_length=255, + default="https://oauth2.googleapis.com/token", + verbose_name="GCP Token URI", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_auth_cert_url", + field=models.CharField( + max_length=255, + default="https://www.googleapis.com/oauth2/v1/certs", + verbose_name="GCP Auth Provider Cert URL", + ), + ), + migrations.AddField( + model_name="cloudservicesconfiguration", + name="gcp_client_cert_url", + field=models.TextField( + default="https://www.googleapis.com/robot/v1/metadata/x509/your-service-account@project.iam.gserviceaccount.com", + verbose_name="GCP Client Cert URL", + ), + ), + ] diff --git a/ghostwriter/commandcenter/models.py b/ghostwriter/commandcenter/models.py index 472572efa..97a062078 100644 --- a/ghostwriter/commandcenter/models.py +++ b/ghostwriter/commandcenter/models.py @@ -74,6 +74,48 @@ class Meta: def sanitized_api_key(self): return sanitize(self.api_key) +class CloudflareConfiguration(SingletonModel): + enable = models.BooleanField(default=False) + api_key = models.CharField(max_length=255, default="Cloudflare API key", help_text="Your Cloudflare API key") + username = models.CharField(max_length=255, default="Account Email", help_text="Your Cloudflare email") + account_id = models.CharField( + "Account ID", + max_length=255, + default="Account ID", + help_text="Your Cloudflare Account ID", + ) + client_ip = models.CharField( + "Whitelisted IP Address", + max_length=255, + default="Whitelisted IP Address", + help_text="Your external IP address registered with Cloudflare", + ) + page_size = models.IntegerField( + "Page Size", + default=100, + help_text="Maximum number of domains to return (100 is the max allowed)", + ) + + def __str__(self): + return "Cloudflare Configuration" + + class Meta: + verbose_name = "Cloudflare Configuration" + + @property + def sanitized_api_key(self): + return sanitize(self.api_key) + + def __str__(self): + return "Cloudflare Configuration" + + class Meta: + verbose_name = "Cloudflare Configuration" + + @property + def sanitized_api_key(self): + return sanitize(self.api_key) + class ReportConfiguration(SingletonModel): enable_borders = models.BooleanField(default=False, help_text="Enable borders around images in Word documents") @@ -281,6 +323,16 @@ class CloudServicesConfiguration(SingletonModel): default=7, help_text="Number of days to delay cloud monitoring notifications for teardown", ) + # GCP-Specific Configuration Fields + gcp_project_id = models.CharField("GCP Project ID", max_length=255, default="your-project-id") + gcp_private_key_id = models.CharField("GCP Private Key ID", max_length=255, default="your-private-key-id") + gcp_private_key = models.TextField("GCP Private Key", default="your-private-key") + gcp_client_email = models.CharField("GCP Client Email", max_length=255, default="your-service-account@example.iam.gserviceaccount.com") + gcp_client_id = models.CharField("GCP Client ID", max_length=255, default="your-client-id") + gcp_auth_uri = models.CharField("GCP Auth URI", max_length=255, default="https://accounts.google.com/o/oauth2/auth") + gcp_token_uri = models.CharField("GCP Token URI", max_length=255, default="https://oauth2.googleapis.com/token") + gcp_auth_cert_url = models.CharField("GCP Auth Provider Cert URL", max_length=255, default="https://www.googleapis.com/oauth2/v1/certs") + gcp_client_cert_url = models.TextField("GCP Client Cert URL", default="https://www.googleapis.com/robot/v1/metadata/x509/your-service-account@example.iam.gserviceaccount.com") def __str__(self): return "Cloud Services Configuration" diff --git a/ghostwriter/factories.py b/ghostwriter/factories.py index c755f4efd..b3dceab6a 100644 --- a/ghostwriter/factories.py +++ b/ghostwriter/factories.py @@ -803,6 +803,17 @@ class Meta: page_size = 100 +class CloudflareConfigurationFactory(factory.django.DjangoModelFactory): + class Meta: + model = "commandcenter.CloudflareConfiguration" + + enable = Faker("boolean") + api_key = Faker("credit_card_number") + username = Faker("user_name") + api_username = Faker("user_name") + client_ip = Faker("ipv4_private") + page_size = 100 + class ReportConfigurationFactory(factory.django.DjangoModelFactory): class Meta: model = "commandcenter.ReportConfiguration" diff --git a/ghostwriter/home/templates/home/management.html b/ghostwriter/home/templates/home/management.html index 1ebe72e3a..aeb9ba2cc 100644 --- a/ghostwriter/home/templates/home/management.html +++ b/ghostwriter/home/templates/home/management.html @@ -18,6 +18,7 @@ {% get_solo "commandcenter.CompanyInformation" as company_config %} {% get_solo "commandcenter.CloudServicesConfiguration" as cloud_config %} {% get_solo "commandcenter.NamecheapConfiguration" as namecheap_config %} + {% get_solo "commandcenter.CloudflareConfiguration" as cloudflare_config %} {% get_solo "commandcenter.ReportConfiguration" as report_config %} {% get_solo "commandcenter.SlackConfiguration" as slack_config %} {% get_solo "commandcenter.VirusTotalConfiguration" as vt_config %} @@ -136,6 +137,37 @@
Cloudflare detected potential phishing activity on this domain.
" + + try: + instance, created = Domain.objects.update_or_create(name=domain["name"], defaults=entry) + instance.save() + change_status = ( + "created & burned" if created and newly_burned else + "burned" if newly_burned else + "created" if created else + "updated" + ) + domain_changes["updates"][instance.id] = {"domain": domain["name"], "change": change_status} + except Exception: + trace = traceback.format_exc() + logger.exception("Failed to update domain %s", domain["name"]) + domain_changes["errors"][domain["name"]] = {"error": trace} + + + logger.info("Cloudflare synchronization completed at %s with these changes:\n%s", datetime.now(), domain_changes) + else: + logger.warning("No domains were returned for the provided Cloudflare account!") + + return domain_changes + def months_between(date1, date2): """ @@ -944,7 +1091,6 @@ def json_datetime_converter(dt): return str(dt) return None - def review_cloud_infrastructure(aws_only_running=False, do_only_running=False): """ Fetch active virtual machines/instances in Digital Ocean, Azure, and AWS and @@ -1027,6 +1173,21 @@ def review_cloud_infrastructure(aws_only_running=False, do_only_running=False): for instance in do_results["instances"]: vps_info["instances"][instance["id"]] = instance + ############### + # GCP Section # + ############### + + # Fetch GCP results using full cloud_config object + gcp_results = fetch_gcp_instances(cloud_config) + + if gcp_results["message"]: + vps_info["errors"]["gcp"] = gcp_results["message"] + else: + if gcp_results["capable"]: + for instance_id, instance in gcp_results["instances"].items(): + vps_info["instances"][instance_id] = instance + + ################## # Notifications # ################## @@ -1427,3 +1588,109 @@ def test_virustotal(user): logger.info("Test of the VirusTotal completed at %s", datetime.now()) return {"result": level, "message": message} + +def test_cloudflare(user): + """ + Test the Cloudflare API configuration stored in environment variables stored in :model:`commandcenter.CloudflareConfiguration`. + """ + level = "error" + logger.info("Starting Cloudflare API test at %s", datetime.now()) + cloudflare_config = CloudflareConfiguration.get_solo() + try: + client = Cloudflare( + api_email=cloudflare_config.username, + api_key=cloudflare_config.api_key + ) + response = client.zones.list() + if response.success: + level = "success" + message = "Successfully authenticated to Cloudflare" + else: + message = "Cloudflare API authentication failed: " + " | ".join([error["message"] for error in response.errors]) + except Exception: + trace = traceback.format_exc() + logger.exception("Cloudflare API request failed") + message = f"The Cloudflare API request failed: {trace}" + return {"result": level, "message": message} + +def format_gcp_private_key(raw_key: str) -> str: + """ + Normalize a GCP private key string to ensure it's in proper PEM format. + Handles keys stored with literal '\\n' as well as already-formatted PEM blocks. + """ + if not raw_key: + return "" + + if "\\n" in raw_key: + raw_key = raw_key.replace("\\n", "\n") + + raw_key = raw_key.strip() + + if not raw_key.startswith("-----BEGIN PRIVATE KEY-----"): + raw_key = f"-----BEGIN PRIVATE KEY-----\n{raw_key}" + if not raw_key.endswith("-----END PRIVATE KEY-----"): + raw_key = f"{raw_key}\n-----END PRIVATE KEY-----" + + return raw_key + +def test_gcp(user): + """ + Test the GCP service account credentials configured in + :model:`commandcenter.CloudServicesConfiguration`. + """ + cloud_config = CloudServicesConfiguration.get_solo() + level = "error" + logger.info("Starting a test of the GCP credentials at %s", datetime.now()) + + private_key = format_gcp_private_key(cloud_config.gcp_private_key) + + try: + # Build credentials from individual variables (not file) + service_account_info = { + "type": "service_account", + "project_id": cloud_config.gcp_project_id, + "private_key_id": cloud_config.gcp_private_key_id, + "private_key": private_key, + "client_email": cloud_config.gcp_client_email, + "client_id": cloud_config.gcp_client_id, + "auth_uri": cloud_config.gcp_auth_uri, + "token_uri": cloud_config.gcp_token_uri, + "auth_provider_x509_cert_url": cloud_config.gcp_auth_cert_url, + "client_x509_cert_url": cloud_config.gcp_client_cert_url + } + + credentials = service_account.Credentials.from_service_account_info(service_account_info) + compute = build('compute', 'v1', credentials=credentials, cache_discovery=False) + + # Attempt a simple API call — list zones in the project + request = compute.zones().list(project=cloud_config.gcp_project_id) + response = request.execute() + + if "items" in response: + logger.info("GCP credentials are functional, beginning infrastructure review") + logger.info("Successfully verified the GCP service account credentials") + message = "Successfully verified the GCP service account credentials" + level = "success" + else: + logger.warning("GCP credentials did not return zone list as expected") + message = "GCP credentials did not return zone list as expected — please double-check access scopes and permissions" + + except Exception as e: + logger.exception("Testing authentication to GCP failed") + message = f"Testing authentication to GCP failed: {str(e)}" + + # Send a message to the requesting user + async_to_sync(channel_layer.group_send)( + f"notify_{user}", + { + "type": "message", + "message": { + "message": message, + "level": level, + "title": "GCP Test Complete", + }, + }, + ) + + logger.info("Test of the GCP credentials completed at %s", datetime.now()) + return {"result": level, "message": message} \ No newline at end of file diff --git a/ghostwriter/shepherd/templates/shepherd/update.html b/ghostwriter/shepherd/templates/shepherd/update.html index 17ab35a6d..2562c0b59 100644 --- a/ghostwriter/shepherd/templates/shepherd/update.html +++ b/ghostwriter/shepherd/templates/shepherd/update.html @@ -95,6 +95,42 @@The domain library sync with Cloudflare was last requested on:
++ {{ cloudflare_last_update_requested }} +
+ + {% if cloudflare_last_update_completed %} + {% if cloudflare_last_update_completed == 'Failed' %} +Request Status: {{ cloudflare_last_update_completed }}
+ {% if cloudflare_last_result %} +Request Status: Completed on {{ cloudflare_last_update_completed }} in {{ cloudflare_last_update_time }} minutes
+ {% endif %} + {% endif %} + {% endif %} + + +