diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index f387077184..ff8838a092 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added +- Support for the new `lovable` provider type: provider choice, UID workspace ID validator, secret schema (`api_token` plus optional `supabase_access_token`), connection-test wiring, and the `0091_lovable_provider` migration - New `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929) - ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982) - `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929) diff --git a/api/src/backend/api/migrations/0091_lovable_provider.py b/api/src/backend/api/migrations/0091_lovable_provider.py new file mode 100644 index 0000000000..bdcd04933a --- /dev/null +++ b/api/src/backend/api/migrations/0091_lovable_provider.py @@ -0,0 +1,41 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0090_attack_paths_cleanup_priority"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ("image", "Image"), + ("googleworkspace", "Google Workspace"), + ("vercel", "Vercel"), + ("lovable", "Lovable"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'lovable';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index e7195cff5f..59d2c02ef5 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -296,6 +296,7 @@ class Provider(RowLevelSecurityProtectedModel): IMAGE = "image", _("Image") GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") VERCEL = "vercel", _("Vercel") + LOVABLE = "lovable", _("Lovable") @staticmethod def validate_aws_uid(value): @@ -448,6 +449,17 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_lovable_uid(value): + # Lovable workspace IDs are alphanumeric (with optional dashes/underscores), + # commonly between 8 and 64 characters. Accept both raw IDs and slugs. + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$", value): + raise ModelValidationError( + detail="Lovable provider ID must be a valid workspace ID or slug (3-64 alphanumeric characters, dashes, or underscores).", + code="lovable-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_image_uid(value): if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{2,249}$", value): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index b80d54b08a..c0a5163289 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -40,6 +40,7 @@ if TYPE_CHECKING: from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider + from prowler.providers.lovable.lovable_provider import LovableProvider class CustomOAuth2Client(OAuth2Client): @@ -96,6 +97,7 @@ def return_prowler_provider( | OpenstackProvider | OraclecloudProvider | VercelProvider + | LovableProvider ): """Return the Prowler provider class based on the given provider type. @@ -181,6 +183,10 @@ def return_prowler_provider( from prowler.providers.vercel.vercel_provider import VercelProvider prowler_provider = VercelProvider + case Provider.ProviderChoices.LOVABLE.value: + from prowler.providers.lovable.lovable_provider import LovableProvider + + prowler_provider = LovableProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -246,6 +252,11 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "team_id": provider.uid, } + elif provider.provider == Provider.ProviderChoices.LOVABLE.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "workspace_id": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -293,6 +304,7 @@ def initialize_prowler_provider( | OpenstackProvider | OraclecloudProvider | VercelProvider + | LovableProvider ): """Initialize a Prowler provider instance based on the given provider type. @@ -351,6 +363,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**vercel_kwargs) + elif provider.provider == Provider.ProviderChoices.LOVABLE.value: + lovable_kwargs = { + **prowler_provider_kwargs, + "workspace_id": provider.uid, + "raise_on_exception": False, + "provider_id": provider.uid, + } + return prowler_provider.test_connection(**lovable_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index f8d67f0e3b..b678d5b75c 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -415,6 +415,21 @@ from rest_framework_json_api import serializers }, "required": ["api_token"], }, + { + "type": "object", + "title": "Lovable API Token", + "properties": { + "api_token": { + "type": "string", + "description": "Lovable Cloud API token used to authenticate against the Lovable Cloud API.", + }, + "supabase_access_token": { + "type": "string", + "description": "Optional Supabase Management API token for deeper RLS / auth posture checks on Supabase-backed Lovable apps.", + }, + }, + "required": ["api_token"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 51cb95fbff..d89a717b26 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1575,6 +1575,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = ImageProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.VERCEL.value: serializer = VercelProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.LOVABLE.value: + serializer = LovableProviderSecret(data=secret) else: raise serializers.ValidationError( {"provider": f"Provider type not supported {provider_type}"} @@ -1788,6 +1790,23 @@ class VercelProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class LovableProviderSecret(serializers.Serializer): + api_token = serializers.CharField( + help_text="Lovable Cloud API token used to authenticate against the Lovable Cloud API.", + ) + supabase_access_token = serializers.CharField( + required=False, + allow_blank=True, + help_text=( + "Optional Supabase Management API token used for deeper RLS / " + "auth posture checks on Supabase-backed Lovable apps." + ), + ) + + class Meta: + resource_name = "provider-secrets" + + class AlibabaCloudProviderSecret(serializers.Serializer): access_key_id = serializers.CharField() access_key_secret = serializers.CharField() diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4a17bb04c2..2b9f658c57 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- New `lovable` provider with `apps` and `published` services, 12 security checks covering Lovable best practices (workspace visibility, pre-publication review, Supabase RLS, Edge Function authentication, storage privacy, CAPTCHA, password policy, auth rate limiting, HTTPS, security headers, strict CSP, no secrets in frontend bundle), and the Prowler ThreatScore for Lovable compliance framework - `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844) - Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301) - ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808) diff --git a/prowler/compliance/lovable/__init__.py b/prowler/compliance/lovable/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/lovable/prowler_threatscore_lovable.json b/prowler/compliance/lovable/prowler_threatscore_lovable.json new file mode 100644 index 0000000000..d7ec552f1e --- /dev/null +++ b/prowler/compliance/lovable/prowler_threatscore_lovable.json @@ -0,0 +1,225 @@ +{ + "Framework": "ProwlerThreatScore", + "Name": "Prowler ThreatScore Compliance Framework for Lovable", + "Version": "1.0", + "Provider": "Lovable", + "Description": "Prowler ThreatScore Compliance Framework for Lovable assesses the security posture of AI-built apps published from Lovable across four pillars: Identity and Access Management (auth, password policy, RLS), Attack Surface (workspace visibility, security headers, HTTPS), Secrets Management (no secrets in frontend, pre-publication review), and Application Hardening (Edge Function auth, CSP, storage privacy).", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "CAPTCHA / bot protection is enabled on Lovable auth forms", + "Checks": [ + "apps_authentication_captcha_enabled" + ], + "Attributes": [ + { + "Title": "Bot protection on auth forms", + "Section": "1. Identity and Access Management", + "SubSection": "1.1 Authentication", + "AttributeDescription": "Signup, login, and password reset forms in Lovable apps must require CAPTCHA so that automated tools cannot spam accounts, enumerate users, or perform credential stuffing at scale.", + "AdditionalInformation": "Lovable's documented best practice is to enable CAPTCHA on every authentication-facing form. Combined with rate limiting, CAPTCHA dramatically reduces the success rate of automated attacks.", + "LevelOfRisk": 3, + "Weight": 100 + } + ] + }, + { + "Id": "1.1.2", + "Description": "Lovable apps enforce a strong password policy", + "Checks": [ + "apps_authentication_strong_password_policy" + ], + "Attributes": [ + { + "Title": "Strong password policy", + "Section": "1. Identity and Access Management", + "SubSection": "1.1 Authentication", + "AttributeDescription": "Lovable apps using password authentication must require at least 8 characters and include uppercase, lowercase, and numeric characters.", + "AdditionalInformation": "Weak password policies enable trivial credential-stuffing and brute-force attacks. The minimum baseline mirrors the Lovable Cyber best-practices guidance.", + "LevelOfRisk": 3, + "Weight": 100 + } + ] + }, + { + "Id": "1.1.3", + "Description": "Authentication endpoints are rate-limited", + "Checks": [ + "apps_authentication_rate_limit_enabled" + ], + "Attributes": [ + { + "Title": "Auth rate limiting", + "Section": "1. Identity and Access Management", + "SubSection": "1.1 Authentication", + "AttributeDescription": "Signup, login, and password reset endpoints must enforce rate limits to slow down credential stuffing, account enumeration, and brute-force attacks.", + "AdditionalInformation": "Rate limits should be applied per-IP and per-user in the Edge Function backing the auth flow, returning HTTP 429 once exceeded.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "1.2.1", + "Description": "Row Level Security is enabled on every Supabase table backing the app", + "Checks": [ + "apps_supabase_rls_enabled_on_all_tables" + ], + "Attributes": [ + { + "Title": "RLS on all tables", + "Section": "1. Identity and Access Management", + "SubSection": "1.2 Authorization", + "AttributeDescription": "RLS is the final layer of defence behind frontend and backend logic. Every Supabase table containing user data must have RLS enabled and a matching policy that scopes rows to the authenticated user, team, or organization.", + "AdditionalInformation": "Tables without RLS are reachable through the public anon key; this is the single most common cause of catastrophic Lovable app data leaks.", + "LevelOfRisk": 5, + "Weight": 1000 + } + ] + }, + { + "Id": "1.2.2", + "Description": "Supabase Edge Functions enforce authentication", + "Checks": [ + "apps_supabase_edge_functions_authenticated" + ], + "Attributes": [ + { + "Title": "Edge Function authentication", + "Section": "1. Identity and Access Management", + "SubSection": "1.2 Authorization", + "AttributeDescription": "Every Supabase Edge Function backing a Lovable app must validate the JWT and apply role/permission checks server-side. Critical behaviour must never rely on browser-only enforcement.", + "AdditionalInformation": "Lovable's guidance is explicit: 'critical behaviour never reaches the browser and every request is evaluated consistently.'", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "2.1.1", + "Description": "Internal Lovable apps use workspace visibility, not public", + "Checks": [ + "apps_workspace_visibility_for_internal_apps" + ], + "Attributes": [ + { + "Title": "Workspace-only access for internal apps", + "Section": "2. Attack Surface", + "SubSection": "2.1 Exposure", + "AttributeDescription": "Apps tagged as internal must use Workspace visibility rather than Public so that only authenticated workspace members can reach them.", + "AdditionalInformation": "Public visibility for internal tooling exposes admin dashboards, staging environments, and weakly-protected functionality to the entire internet.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "2.1.2", + "Description": "Published apps are served over HTTPS", + "Checks": [ + "published_app_uses_https" + ], + "Attributes": [ + { + "Title": "HTTPS enforcement", + "Section": "2. Attack Surface", + "SubSection": "2.1 Exposure", + "AttributeDescription": "Every published Lovable app must be reachable only over HTTPS so that credentials, JWTs, and user data are encrypted in transit.", + "AdditionalInformation": "Pair HTTPS with HSTS (preload + includeSubDomains) to prevent downgrade attacks.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "2.2.1", + "Description": "Required HTTP security headers are configured", + "Checks": [ + "published_app_security_headers_configured" + ], + "Attributes": [ + { + "Title": "Baseline security headers", + "Section": "2. Attack Surface", + "SubSection": "2.2 Hardening", + "AttributeDescription": "Published apps must respond with Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy.", + "AdditionalInformation": "These headers mitigate XSS, clickjacking, MIME sniffing, downgrade, and referrer leak attacks.", + "LevelOfRisk": 3, + "Weight": 100 + } + ] + }, + { + "Id": "2.2.2", + "Description": "Content-Security-Policy is strict", + "Checks": [ + "published_app_strict_csp_configured" + ], + "Attributes": [ + { + "Title": "Strict CSP", + "Section": "2. Attack Surface", + "SubSection": "2.2 Hardening", + "AttributeDescription": "The CSP returned by the published app must define default-src and must not contain wildcards or 'unsafe-inline'/'unsafe-eval' for script execution.", + "AdditionalInformation": "A permissive CSP is a permissive lie: it gives the appearance of XSS protection while still allowing attacker-injected scripts to execute.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "2.2.3", + "Description": "Public storage buckets are not used for private user content", + "Checks": [ + "apps_supabase_storage_buckets_not_public" + ], + "Attributes": [ + { + "Title": "Storage bucket privacy", + "Section": "2. Attack Surface", + "SubSection": "2.2 Hardening", + "AttributeDescription": "Supabase storage buckets must be Private by default. Only buckets serving genuinely public assets should remain Public.", + "AdditionalInformation": "Public buckets allow any visitor with the URL to enumerate and download files. Combine Private buckets with storage RLS and signed URLs for downloads.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + }, + { + "Id": "3.1.1", + "Description": "No secrets are bundled into the published frontend", + "Checks": [ + "published_app_no_secrets_in_frontend_bundle" + ], + "Attributes": [ + { + "Title": "No secrets in browser bundle", + "Section": "3. Secrets Management", + "SubSection": "3.1 Frontend exposure", + "AttributeDescription": "Service-role keys, third-party API tokens, and private keys must never appear in the JavaScript bundle delivered to the browser.", + "AdditionalInformation": "A leaked Supabase service-role key bypasses RLS entirely. Anything shipped to the browser must be considered compromised the moment it ships.", + "LevelOfRisk": 5, + "Weight": 1000 + } + ] + }, + { + "Id": "3.2.1", + "Description": "Lovable's pre-publication security review has been completed", + "Checks": [ + "apps_pre_publication_security_review_completed" + ], + "Attributes": [ + { + "Title": "Pre-publication security review", + "Section": "3. Secrets Management", + "SubSection": "3.2 Governance", + "AttributeDescription": "Lovable's built-in 'Review Security' workflow must be run, and every reported finding must be resolved, before an app is published.", + "AdditionalInformation": "The historical wave of Lovable app data leaks was driven by skipped pre-publication reviews; treating it as a release gate prevents the most common failure modes.", + "LevelOfRisk": 4, + "Weight": 100 + } + ] + } + ] +} diff --git a/prowler/config/config.py b/prowler/config/config.py index aa81e81136..ee8f3fb07a 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -76,6 +76,7 @@ class Provider(str, Enum): OPENSTACK = "openstack" IMAGE = "image" VERCEL = "vercel" + LOVABLE = "lovable" # Compliance diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index f5f0ff87e8..7cc0687866 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1283,6 +1283,42 @@ class CheckReportVercel(Check_Report): return "global" +@dataclass +class CheckReportLovable(Check_Report): + """Contains the Lovable Check's finding information. + + Lovable is a global platform - workspace_id is the scoping context. + All resource-related attributes are derived from the resource object. + """ + + resource_name: str + resource_id: str + workspace_id: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str = None, + resource_id: str = None, + workspace_id: str = None, + ) -> None: + """Initialize the Lovable Check's finding information.""" + super().__init__(metadata, resource) + self.resource_name = resource_name or getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = resource_id or getattr( + resource, "id", getattr(resource, "resource_id", "") + ) + self.workspace_id = workspace_id or getattr(resource, "workspace_id", "") + + @property + def region(self) -> str: + """Lovable is global - return 'global'.""" + return "global" + + # Testing Pending def load_check_metadata(metadata_file: str) -> CheckMetadata: """ diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 24aa02083d..cb6f54294a 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -29,10 +29,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,lovable,dashboard,iac,image,llm} ...", epilog=""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel} + {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,lovable} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -50,6 +50,7 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider vercel Vercel Provider + lovable Lovable Provider Available components: dashboard Local dashboard diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 2c2638ed65..918feb6262 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -427,6 +427,23 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = "global" + elif provider.type == "lovable": + output_data["auth_method"] = "api_token" + workspace = get_nested_attribute(provider, "identity.workspace") + output_data["account_uid"] = ( + workspace.id + if workspace + else get_nested_attribute(provider, "identity.user_id") + ) + output_data["account_name"] = ( + workspace.name or workspace.slug + if workspace + else get_nested_attribute(provider, "identity.username") + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = "global" + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index 1e2f6f2058..32444e1646 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -40,6 +40,8 @@ def stdout_report(finding, color, verbose, status, fix): details = finding.location if finding.check_metadata.Provider == "vercel": details = finding.region + if finding.check_metadata.Provider == "lovable": + details = finding.region if (verbose or fix) and (not status or finding.status in status): if finding.muted: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 6ed09ed159..1a9ddb52a3 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -108,6 +108,13 @@ def display_summary_table( ) else: audited_entities = provider.identity.username or "Personal Account" + elif provider.type == "lovable": + entity_type = "Workspace" + if provider.identity.workspace: + ws = provider.identity.workspace + audited_entities = ws.name or ws.slug or ws.id + else: + audited_entities = provider.identity.username or "Personal Account" # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index b0486d9410..ae3dc2a78b 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -403,6 +403,21 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif "lovable" in provider_class_name.lower(): + provider_class( + api_token=getattr(arguments, "lovable_api_token", None), + workspace_id=getattr(arguments, "lovable_workspace_id", None), + supabase_access_token=getattr( + arguments, "supabase_access_token", None + ), + projects=getattr(arguments, "project", None), + published_app_urls=getattr( + arguments, "published_app_url", None + ), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) except TypeError as error: logger.critical( diff --git a/prowler/providers/lovable/__init__.py b/prowler/providers/lovable/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/config.py b/prowler/providers/lovable/config.py new file mode 100644 index 0000000000..970dd16806 --- /dev/null +++ b/prowler/providers/lovable/config.py @@ -0,0 +1,45 @@ +"""Lovable provider configuration. + +Lovable apps are AI-generated web apps published from the Lovable platform. +They are typically backed by Supabase (auth + database + storage) and run on +edge runtimes that expose the published frontend bundle, Edge Functions, and +HTTP security headers. + +The provider talks to: + * Lovable Cloud API -> project / app metadata, workspace settings. + * Published app URL -> live HTTP fetch for security headers + secret scan. + * Optional Supabase -> RLS / auth posture (when an access token is provided). +""" + +LOVABLE_API_BASE_URL = "https://api.lovable.dev" +LOVABLE_API_VERSION = "v1" +LOVABLE_DEFAULT_TIMEOUT = 30 +LOVABLE_USER_AGENT = "Prowler-Lovable-Provider" + +# Sentinel patterns used to detect secrets accidentally published to the +# frontend bundle. Order matters: more specific patterns first. +SECRET_PATTERNS = ( + # Supabase service-role JWT (anon key prefix differs in role claim) + (r"eyJhbGciOi[\w-]+\.eyJ[\w-]+\.[\w-]+", "supabase_jwt"), + # OpenAI / Anthropic / generic API keys + (r"sk-[A-Za-z0-9]{20,}", "openai_api_key"), + (r"sk-ant-[A-Za-z0-9-_]{20,}", "anthropic_api_key"), + (r"AKIA[0-9A-Z]{16}", "aws_access_key_id"), + (r"AIza[0-9A-Za-z_\-]{35}", "google_api_key"), + (r"ghp_[A-Za-z0-9]{36,}", "github_pat"), + (r"sbp_[A-Za-z0-9]{40,}", "supabase_pat"), + (r"xoxb-[A-Za-z0-9-]{20,}", "slack_bot_token"), + (r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----", "private_key"), +) + +# HTTP security headers Prowler considers mandatory for a published Lovable app. +REQUIRED_SECURITY_HEADERS = ( + "content-security-policy", + "strict-transport-security", + "x-content-type-options", + "x-frame-options", + "referrer-policy", +) + +# Minimum password policy required by best practices. +MIN_PASSWORD_LENGTH = 8 diff --git a/prowler/providers/lovable/exceptions/__init__.py b/prowler/providers/lovable/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/exceptions/exceptions.py b/prowler/providers/lovable/exceptions/exceptions.py new file mode 100644 index 0000000000..4948ff5c8b --- /dev/null +++ b/prowler/providers/lovable/exceptions/exceptions.py @@ -0,0 +1,155 @@ +# Exception codes 14000 to 14999 are reserved for the Lovable provider. +from prowler.exceptions.exceptions import ProwlerException + + +class LovableBaseException(ProwlerException): + """Base exception for Lovable provider errors.""" + + LOVABLE_ERROR_CODES = { + (14000, "LovableCredentialsError"): { + "message": "Lovable credentials not found or invalid.", + "remediation": ( + "Set the LOVABLE_API_TOKEN environment variable with a valid " + "Lovable Cloud API token. Generate one from your Lovable " + "workspace settings." + ), + }, + (14001, "LovableAuthenticationError"): { + "message": "Authentication to the Lovable Cloud API failed.", + "remediation": ( + "Verify the LOVABLE_API_TOKEN is valid, has not been revoked, " + "and grants access to the target workspace." + ), + }, + (14002, "LovableSessionError"): { + "message": "Failed to create a Lovable API session.", + "remediation": ( + "Check network connectivity to https://api.lovable.dev and " + "retry the request." + ), + }, + (14003, "LovableIdentityError"): { + "message": "Failed to retrieve Lovable identity information.", + "remediation": ( + "Ensure the API token has read access to the workspace and " + "user profile." + ), + }, + (14004, "LovableInvalidWorkspaceError"): { + "message": "The specified Lovable workspace was not found or is not accessible.", + "remediation": ( + "Verify the workspace ID/slug is correct and that the API " + "token has access to it." + ), + }, + (14005, "LovableInvalidProviderIdError"): { + "message": "The provided Lovable provider ID is invalid.", + "remediation": ( + "Ensure the provider UID matches a valid Lovable workspace ID " + "(format: ws_<24+ hex chars>)." + ), + }, + (14006, "LovableAPIError"): { + "message": "An error occurred while calling the Lovable Cloud API.", + "remediation": ( + "Check the Lovable status page and retry. If the error " + "persists, run with --log-level DEBUG and inspect logs." + ), + }, + (14007, "LovableRateLimitError"): { + "message": "Rate limited by the Lovable Cloud API.", + "remediation": ( + "Wait and retry. Reduce concurrent project scans by passing " + "--project to scope the assessment." + ), + }, + (14008, "LovablePublishedAppFetchError"): { + "message": "Could not fetch the published Lovable app over HTTP.", + "remediation": ( + "Verify the app is published, publicly reachable, and that " + "Prowler can resolve its hostname." + ), + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Lovable" + error_info = self.LOVABLE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Lovable error.", + "remediation": "Check the Lovable documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class LovableCredentialsError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14000, file=file, original_exception=original_exception, message=message + ) + + +class LovableAuthenticationError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14001, file=file, original_exception=original_exception, message=message + ) + + +class LovableSessionError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14002, file=file, original_exception=original_exception, message=message + ) + + +class LovableIdentityError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14003, file=file, original_exception=original_exception, message=message + ) + + +class LovableInvalidWorkspaceError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14004, file=file, original_exception=original_exception, message=message + ) + + +class LovableInvalidProviderIdError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14005, file=file, original_exception=original_exception, message=message + ) + + +class LovableAPIError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14006, file=file, original_exception=original_exception, message=message + ) + + +class LovableRateLimitError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14007, file=file, original_exception=original_exception, message=message + ) + + +class LovablePublishedAppFetchError(LovableBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14008, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/lovable/lib/__init__.py b/prowler/providers/lovable/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/lib/arguments/__init__.py b/prowler/providers/lovable/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/lib/arguments/arguments.py b/prowler/providers/lovable/lib/arguments/arguments.py new file mode 100644 index 0000000000..0e1f6d472e --- /dev/null +++ b/prowler/providers/lovable/lib/arguments/arguments.py @@ -0,0 +1,81 @@ +"""Lovable provider CLI arguments. + +Authentication relies on environment variables by default. Sensitive flags are +listed in `SENSITIVE_ARGUMENTS` so the redactor and HTML output can scrub +their values when a user passes them on the command line. +""" + +# Flags whose values must be redacted in HTML output and warned about when +# passed directly. The recommended path for all of these is environment +# variables. +SENSITIVE_ARGUMENTS = frozenset( + { + "--lovable-api-token", + "--supabase-access-token", + } +) + + +def init_parser(self): + """Init the Lovable provider CLI parser.""" + lovable_parser = self.subparsers.add_parser( + "lovable", + parents=[self.common_providers_parser], + help="Lovable Provider", + ) + + # Authentication + auth_group = lovable_parser.add_argument_group("Authentication Modes") + auth_group.add_argument( + "--lovable-api-token", + nargs="?", + default=None, + metavar="LOVABLE_API_TOKEN", + help=( + "Lovable Cloud API token. Prefer the LOVABLE_API_TOKEN environment " + "variable instead of passing the value on the command line." + ), + ) + auth_group.add_argument( + "--lovable-workspace-id", + nargs="?", + default=None, + metavar="LOVABLE_WORKSPACE_ID", + help=( + "Restrict the assessment to a single Lovable workspace. Falls " + "back to the LOVABLE_WORKSPACE_ID environment variable." + ), + ) + auth_group.add_argument( + "--supabase-access-token", + nargs="?", + default=None, + metavar="SUPABASE_ACCESS_TOKEN", + help=( + "Optional Supabase Management API token used for deeper RLS / " + "auth posture checks on Supabase-backed Lovable apps. Prefer the " + "SUPABASE_ACCESS_TOKEN environment variable." + ), + ) + + # Scope + scope_group = lovable_parser.add_argument_group("Scope") + scope_group.add_argument( + "--project", + "--projects", + nargs="*", + default=None, + metavar="PROJECT", + help="Filter scan to specific Lovable project IDs or slugs.", + ) + scope_group.add_argument( + "--published-app-url", + nargs="*", + default=None, + metavar="URL", + help=( + "Optional explicit list of published Lovable app URLs to fetch " + "for HTTP header / secret scan checks (e.g. when the API does " + "not expose them)." + ), + ) diff --git a/prowler/providers/lovable/lib/mutelist/__init__.py b/prowler/providers/lovable/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/lib/mutelist/mutelist.py b/prowler/providers/lovable/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..e527369599 --- /dev/null +++ b/prowler/providers/lovable/lib/mutelist/mutelist.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import CheckReportLovable +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class LovableMutelist(Mutelist): + """Lovable-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportLovable, + workspace_id: str, + ) -> bool: + return self.is_muted( + workspace_id, + finding.check_metadata.CheckID, + "global", # Lovable is global + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/lovable/lib/service/__init__.py b/prowler/providers/lovable/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/lib/service/service.py b/prowler/providers/lovable/lib/service/service.py new file mode 100644 index 0000000000..ab55c4d41b --- /dev/null +++ b/prowler/providers/lovable/lib/service/service.py @@ -0,0 +1,155 @@ +"""Base service class for Lovable services. + +Provides a thin HTTP layer with retry, rate-limit awareness, and the +authenticated session shared across services. +""" + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests + +from prowler.lib.logger import logger +from prowler.providers.lovable.config import ( + LOVABLE_DEFAULT_TIMEOUT, + LOVABLE_USER_AGENT, +) +from prowler.providers.lovable.exceptions.exceptions import ( + LovableAPIError, + LovableRateLimitError, +) + +MAX_WORKERS = 8 + + +class LovableService: + """Base class shared by every Lovable service.""" + + def __init__(self, service: str, provider): + self.provider = provider + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + self._http_session = requests.Session() + self._http_session.headers.update( + { + "Authorization": f"Bearer {provider.session.api_token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": LOVABLE_USER_AGENT, + } + ) + self._base_url = provider.session.base_url + self._workspace_id = provider.session.workspace_id + + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def _get(self, path: str, params: dict | None = None) -> dict | None: + """GET wrapper with retry and rate-limit handling. + + Returns parsed JSON dict on success, None on auth/scope failures so the + caller can degrade gracefully. + """ + params = dict(params or {}) + if self._workspace_id and "workspaceId" not in params: + params["workspaceId"] = self._workspace_id + + url = f"{self._base_url}{path}" + max_retries = self.audit_config.get("max_retries", 3) + + for attempt in range(max_retries + 1): + try: + response = self._http_session.get( + url, params=params, timeout=LOVABLE_DEFAULT_TIMEOUT + ) + + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 5)) + if attempt < max_retries: + logger.warning( + f"{self.service} - Rate limited on {path}, " + f"retrying after {retry_after}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_after) + continue + raise LovableRateLimitError( + file=__file__, + message=f"Rate limited on {path} after {max_retries} retries.", + ) + + if response.status_code in (401, 403): + logger.info( + f"{self.service} - {response.status_code} on {path}; " + "skipping (token may lack scope)." + ) + return None + + if response.status_code == 404: + return None + + response.raise_for_status() + return response.json() if response.content else {} + + except LovableRateLimitError: + raise + except requests.exceptions.HTTPError as error: + raise LovableAPIError( + file=__file__, + original_exception=error, + message=f"HTTP error on {path}: {error}", + ) + except requests.exceptions.RequestException as error: + if attempt < max_retries: + logger.warning( + f"{self.service} - Request error on {path}, retrying " + f"(attempt {attempt + 1}/{max_retries}): {error}" + ) + time.sleep(2**attempt) + continue + raise LovableAPIError( + file=__file__, + original_exception=error, + message=f"Request failed on {path} after {max_retries} retries: {error}", + ) + + return None + + def _paginate(self, path: str, key: str, params: dict | None = None) -> list: + """Simple cursor pagination helper for Lovable list endpoints.""" + params = dict(params or {}) + params.setdefault("limit", 100) + + items: list = [] + cursor = None + while True: + if cursor: + params["cursor"] = cursor + data = self._get(path, params) + if not data: + break + items.extend(data.get(key, []) or []) + cursor = (data.get("pagination") or {}).get("next") + if not cursor: + break + return items + + def __threading_call__(self, call, iterator): + """Run `call` for every item in `iterator` using the shared pool.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as error: + item = futures[future] + item_id = getattr(item, "id", str(item)) + logger.error( + f"{self.service} - Threading error on {item_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return results diff --git a/prowler/providers/lovable/lovable_provider.py b/prowler/providers/lovable/lovable_provider.py new file mode 100644 index 0000000000..1b999f6eab --- /dev/null +++ b/prowler/providers/lovable/lovable_provider.py @@ -0,0 +1,399 @@ +"""Lovable Provider. + +Authenticates against the Lovable Cloud API using a workspace-scoped API +token, optionally augments findings with Supabase posture data, and exposes +projects + their published apps for security assessment. +""" + +import os + +import requests +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.lovable.config import ( + LOVABLE_API_BASE_URL, + LOVABLE_API_VERSION, + LOVABLE_DEFAULT_TIMEOUT, + LOVABLE_USER_AGENT, +) +from prowler.providers.lovable.exceptions.exceptions import ( + LovableAuthenticationError, + LovableCredentialsError, + LovableIdentityError, + LovableInvalidProviderIdError, + LovableInvalidWorkspaceError, + LovableRateLimitError, + LovableSessionError, +) +from prowler.providers.lovable.lib.mutelist.mutelist import LovableMutelist +from prowler.providers.lovable.models import ( + LovableIdentityInfo, + LovableSession, + LovableWorkspaceInfo, +) + + +class LovableProvider(Provider): + """Provider for Lovable AI app builder workspaces and published apps.""" + + _type: str = "lovable" + _session: LovableSession + _identity: LovableIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: LovableMutelist + _filter_projects: set[str] | None + audit_metadata: Audit_Metadata + + def __init__( + self, + # Authentication + api_token: str | None = None, + workspace_id: str | None = None, + supabase_access_token: str | None = None, + # Scope + projects: list[str] | None = None, + published_app_urls: list[str] | None = None, + # Provider configuration + config_path: str | None = None, + config_content: dict | None = None, + fixer_config: dict | None = None, + mutelist_path: str | None = None, + mutelist_content: dict | None = None, + ): + logger.info("Instantiating Lovable provider...") + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = LovableProvider.setup_session( + api_token=api_token, + workspace_id=workspace_id, + ) + + self._identity = LovableProvider.setup_identity(self._session) + + self._fixer_config = fixer_config or {} + self._supabase_access_token = supabase_access_token or os.environ.get( + "SUPABASE_ACCESS_TOKEN" + ) + self._published_app_urls = published_app_urls or [] + + if mutelist_content: + self._mutelist = LovableMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = LovableMutelist(mutelist_path=mutelist_path) + + self._filter_projects = set(projects) if projects else None + + Provider.set_global_provider(self) + + @property + def type(self) -> str: + return self._type + + @property + def session(self) -> LovableSession: + return self._session + + @property + def identity(self) -> LovableIdentityInfo: + return self._identity + + @property + def audit_config(self) -> dict: + return self._audit_config + + @property + def fixer_config(self) -> dict: + return self._fixer_config + + @property + def mutelist(self) -> LovableMutelist: + return self._mutelist + + @property + def filter_projects(self) -> set[str] | None: + return self._filter_projects + + @property + def published_app_urls(self) -> list[str]: + return self._published_app_urls + + @property + def supabase_access_token(self) -> str | None: + return self._supabase_access_token + + @staticmethod + def setup_session( + api_token: str | None = None, + workspace_id: str | None = None, + ) -> LovableSession: + """Build the authenticated Lovable session. + + Credentials may be passed as arguments (API use) or pulled from env vars + (LOVABLE_API_TOKEN, LOVABLE_WORKSPACE_ID). + """ + token = api_token or os.environ.get("LOVABLE_API_TOKEN", "") + workspace = workspace_id or os.environ.get("LOVABLE_WORKSPACE_ID") or None + + if not token: + raise LovableCredentialsError( + file=os.path.basename(__file__), + message=( + "Lovable credentials not found. Provide --lovable-api-token " + "or set the LOVABLE_API_TOKEN environment variable." + ), + ) + + try: + http_session = requests.Session() + http_session.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": LOVABLE_USER_AGENT, + } + ) + + return LovableSession( + api_token=token, + workspace_id=workspace, + base_url=f"{LOVABLE_API_BASE_URL}/{LOVABLE_API_VERSION}", + http_session=http_session, + ) + except LovableCredentialsError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LovableSessionError( + file=os.path.basename(__file__), original_exception=error + ) + + @staticmethod + def setup_identity(session: LovableSession) -> LovableIdentityInfo: + """Resolve identity by calling /v1/me; degrade to a token-derived + identity when the endpoint is unreachable.""" + try: + response = session.http_session.get( + f"{session.base_url}/me", + timeout=LOVABLE_DEFAULT_TIMEOUT, + ) + + if response.status_code == 401: + raise LovableAuthenticationError( + file=os.path.basename(__file__), + message="Lovable authentication failed. Verify the API token.", + ) + + if response.status_code == 429: + raise LovableRateLimitError(file=os.path.basename(__file__)) + + if response.status_code >= 400: + # The Cloud API is still evolving; fall back to a token-derived + # identity so checks targeting the published app still run. + logger.info( + f"Lovable /me returned {response.status_code}; using " + "token-derived identity." + ) + return LovableIdentityInfo( + user_id=None, + username=f"pat-{session.api_token[:8]}", + workspace=( + LovableWorkspaceInfo(id=session.workspace_id) + if session.workspace_id + else None + ), + workspaces=[], + ) + + data = response.json() or {} + user = data.get("user") or {} + workspaces_payload = data.get("workspaces") or [] + + workspaces = [ + LovableWorkspaceInfo( + id=w.get("id", ""), + name=w.get("name", ""), + slug=w.get("slug", ""), + plan=w.get("plan"), + ) + for w in workspaces_payload + if w.get("id") + ] + + workspace = None + if session.workspace_id: + workspace = next( + (w for w in workspaces if w.id == session.workspace_id), + None, + ) + if not workspace and workspaces_payload: + raise LovableInvalidWorkspaceError( + file=os.path.basename(__file__), + message=( + f"Workspace '{session.workspace_id}' not found or " + "not accessible by the provided token." + ), + ) + elif workspaces: + workspace = workspaces[0] + + return LovableIdentityInfo( + user_id=user.get("id"), + email=user.get("email"), + username=user.get("username") or user.get("name"), + workspace=workspace, + workspaces=workspaces, + ) + except ( + LovableAuthenticationError, + LovableRateLimitError, + LovableInvalidWorkspaceError, + ): + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LovableIdentityError( + file=os.path.basename(__file__), original_exception=error + ) + + @staticmethod + def validate_credentials(session: LovableSession) -> None: + """Hit /v1/me to confirm the token is valid.""" + try: + response = session.http_session.get( + f"{session.base_url}/me", + timeout=LOVABLE_DEFAULT_TIMEOUT, + ) + + if response.status_code in (401, 403): + raise LovableAuthenticationError( + file=os.path.basename(__file__), + message="Invalid or insufficient Lovable API token.", + ) + + if response.status_code == 429: + raise LovableRateLimitError(file=os.path.basename(__file__)) + + response.raise_for_status() + except (LovableAuthenticationError, LovableRateLimitError): + raise + except requests.exceptions.RequestException as error: + raise LovableAuthenticationError( + file=os.path.basename(__file__), original_exception=error + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Lovable credentials below:{Style.RESET_ALL}" + ) + report_lines = [ + f"Authentication: {Fore.YELLOW}API Token{Style.RESET_ALL}", + ] + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + if self.identity.workspace: + ws = self.identity.workspace + report_lines.append( + f"Workspace: {Fore.YELLOW}{ws.name or ws.slug or ws.id}" + f"{Style.RESET_ALL}" + ) + elif self.identity.workspaces: + ws_names = ", ".join( + w.name or w.slug or w.id for w in self.identity.workspaces + ) + report_lines.append( + f"Scope: {Fore.YELLOW}{len(self.identity.workspaces)} workspace(s): " + f"{ws_names}{Style.RESET_ALL}" + ) + if self.supabase_access_token: + report_lines.append( + f"Supabase Backing: {Fore.GREEN}enabled{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + api_token: str | None = None, + workspace_id: str | None = None, + raise_on_exception: bool = True, + provider_id: str | None = None, + ) -> Connection: + """Test connection to Lovable Cloud.""" + try: + session = LovableProvider.setup_session( + api_token=api_token, workspace_id=workspace_id + ) + LovableProvider.validate_credentials(session) + + if provider_id: + identity = LovableProvider.setup_identity(session) + workspace_ids = {w.id for w in identity.workspaces} + if identity.workspace and identity.workspace.id: + workspace_ids.add(identity.workspace.id) + if workspace_ids and provider_id not in workspace_ids: + raise LovableInvalidProviderIdError( + file=os.path.basename(__file__), + message=( + "The provided credentials do not have access to the " + f"Lovable workspace with ID: {provider_id}" + ), + ) + + return Connection(is_connected=True) + except ( + LovableCredentialsError, + LovableSessionError, + LovableAuthenticationError, + LovableRateLimitError, + LovableInvalidWorkspaceError, + LovableInvalidProviderIdError, + ) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(is_connected=False, error=error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + formatted_error = LovableAuthenticationError( + file=os.path.basename(__file__), original_exception=error + ) + if raise_on_exception: + raise formatted_error + return Connection(is_connected=False, error=formatted_error) + + def validate_arguments(self) -> None: + return None diff --git a/prowler/providers/lovable/models.py b/prowler/providers/lovable/models.py new file mode 100644 index 0000000000..a71b9166a9 --- /dev/null +++ b/prowler/providers/lovable/models.py @@ -0,0 +1,56 @@ +from typing import Any, Optional + +from pydantic.v1 import BaseModel, Field + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class LovableSession(BaseModel): + """Authenticated Lovable session.""" + + api_token: str = Field(..., min_length=1) + workspace_id: Optional[str] = None + base_url: str = "https://api.lovable.dev/v1" + http_session: Any = Field(default=None, exclude=True) + + class Config: + arbitrary_types_allowed = True + + +class LovableWorkspaceInfo(BaseModel): + """Lovable workspace metadata.""" + + id: str + name: str = "" + slug: str = "" + plan: Optional[str] = None + + +class LovableIdentityInfo(BaseModel): + """Lovable identity returned by /v1/me.""" + + user_id: Optional[str] = None + email: Optional[str] = None + username: Optional[str] = None + workspace: Optional[LovableWorkspaceInfo] = None + workspaces: list[LovableWorkspaceInfo] = Field(default_factory=list) + + +class LovableOutputOptions(ProviderOutputOptions): + """Customize output filenames for Lovable scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: LovableIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + scope = ( + identity.workspace.slug + if identity.workspace + else identity.username or "lovable" + ) + self.output_filename = f"prowler-output-{scope}-{output_file_timestamp}" + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/lovable/services/__init__.py b/prowler/providers/lovable/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/__init__.py b/prowler/providers/lovable/services/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/__init__.py b/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.metadata.json b/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.metadata.json new file mode 100644 index 0000000000..3e47b000f1 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "lovable", + "CheckID": "apps_authentication_captcha_enabled", + "CheckTitle": "CAPTCHA must be enabled on Lovable auth forms", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "LovableApp", + "ResourceGroup": "IAM", + "Description": "Lovable apps that expose authentication must require CAPTCHA on signup and login forms to deter automated bot attacks.", + "Risk": "Without CAPTCHA, attackers can automate signup spam, credential stuffing, brute force attacks, and account-takeover attempts at scale.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://meetcyber.net/security-best-practices-for-lovable-apps-2026-be0350cc87e1" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the Lovable dashboard\n2. Navigate to project Settings > Authentication\n3. Enable CAPTCHA / Bot Protection on signup, login, and password reset forms\n4. Test that the CAPTCHA challenge is presented on each form", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable CAPTCHA on every authentication-facing form so automated tools cannot enumerate users or brute-force credentials.", + "Url": "https://hub.prowler.com/check/apps_authentication_captcha_enabled" + } + }, + "Categories": [ + "identity-access", + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [ + "apps_authentication_rate_limit_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.py b/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.py new file mode 100644 index 0000000000..d03944f459 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_captcha_enabled/apps_authentication_captcha_enabled.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportLovable +from prowler.providers.lovable.services.apps.apps_client import apps_client + + +class apps_authentication_captcha_enabled(Check): + """CAPTCHA / bot protection must be enabled on signup and login forms.""" + + def execute(self) -> list[CheckReportLovable]: + findings: list[CheckReportLovable] = [] + for app in apps_client.apps.values(): + report = CheckReportLovable(metadata=self.metadata(), resource=app) + if not app.auth_enabled: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) does not expose authentication; " + "CAPTCHA is not applicable." + ) + elif app.captcha_enabled: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) has CAPTCHA enabled on auth forms." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) exposes authentication without " + "CAPTCHA, allowing automated credential stuffing." + ) + findings.append(report) + return findings diff --git a/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/__init__.py b/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.metadata.json b/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.metadata.json new file mode 100644 index 0000000000..b2519759df --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "lovable", + "CheckID": "apps_authentication_rate_limit_enabled", + "CheckTitle": "Lovable apps must rate-limit authentication endpoints", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "LovableApp", + "ResourceGroup": "IAM", + "Description": "Signup, login, and password reset endpoints in Lovable apps must be rate-limited to prevent automated credential stuffing, account enumeration, and brute-force attacks.", + "Risk": "Without rate limits, attackers can iterate through credentials, enumerate valid users, or trigger password reset spam at near-unlimited speed.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.lovable.dev/tips-tricks/security-best-practices" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Implement rate limiting in Supabase Edge Functions backing the auth flow\n2. Apply per-IP and per-user limits to signup, login, and password reset endpoints\n3. Return HTTP 429 with Retry-After when limits are exceeded\n4. Monitor authentication metrics for abnormal spikes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Rate-limit authentication and other sensitive endpoints in Edge Functions. Combine with CAPTCHA, anomaly detection, and account-lockout policies for defense in depth.", + "Url": "https://hub.prowler.com/check/apps_authentication_rate_limit_enabled" + } + }, + "Categories": [ + "identity-access", + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [ + "apps_authentication_captcha_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.py b/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.py new file mode 100644 index 0000000000..ed06125631 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_rate_limit_enabled/apps_authentication_rate_limit_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportLovable +from prowler.providers.lovable.services.apps.apps_client import apps_client + + +class apps_authentication_rate_limit_enabled(Check): + """Auth endpoints (signup / login / password reset) must be rate limited.""" + + def execute(self) -> list[CheckReportLovable]: + findings: list[CheckReportLovable] = [] + for app in apps_client.apps.values(): + report = CheckReportLovable(metadata=self.metadata(), resource=app) + if not app.auth_enabled: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) has no authentication " + "endpoints to rate-limit." + ) + elif app.auth_rate_limit_enabled: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) rate-limits authentication " + "endpoints." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) does not rate-limit " + "signup/login/password reset endpoints." + ) + findings.append(report) + return findings diff --git a/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/__init__.py b/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.metadata.json b/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.metadata.json new file mode 100644 index 0000000000..3020563763 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "lovable", + "CheckID": "apps_authentication_strong_password_policy", + "CheckTitle": "Lovable apps must enforce a strong password policy", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "LovableApp", + "ResourceGroup": "IAM", + "Description": "Lovable apps using password authentication must enforce a strong password policy: minimum 8 characters, with uppercase, lowercase, and numeric requirements.", + "Risk": "Weak password policies allow users to create trivially guessable credentials, dramatically increasing the success rate of credential stuffing, dictionary, and brute-force attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.lovable.dev/tips-tricks/security-best-practices" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the Lovable dashboard\n2. Navigate to project Settings > Authentication > Password policy\n3. Set minimum length to 8 or higher\n4. Require at least one uppercase letter, one lowercase letter, and one number\n5. Save and re-test signup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure password policies that require at least 8 characters with uppercase, lowercase, and numeric character classes. Consider also requiring a symbol and integrating with a breached-password list.", + "Url": "https://hub.prowler.com/check/apps_authentication_strong_password_policy" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.py b/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.py new file mode 100644 index 0000000000..717fc2fb65 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_authentication_strong_password_policy/apps_authentication_strong_password_policy.py @@ -0,0 +1,48 @@ +from prowler.lib.check.models import Check, CheckReportLovable +from prowler.providers.lovable.config import MIN_PASSWORD_LENGTH +from prowler.providers.lovable.services.apps.apps_client import apps_client + + +class apps_authentication_strong_password_policy(Check): + """Password policy must require >=8 chars, uppercase, lowercase, and number.""" + + def execute(self) -> list[CheckReportLovable]: + findings: list[CheckReportLovable] = [] + for app in apps_client.apps.values(): + report = CheckReportLovable(metadata=self.metadata(), resource=app) + + if not app.auth_enabled: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) does not use password auth; " + "policy is not applicable." + ) + findings.append(report) + continue + + problems: list[str] = [] + if app.password_min_length < MIN_PASSWORD_LENGTH: + problems.append( + f"min length {app.password_min_length} < {MIN_PASSWORD_LENGTH}" + ) + if not app.password_requires_uppercase: + problems.append("uppercase required = false") + if not app.password_requires_lowercase: + problems.append("lowercase required = false") + if not app.password_requires_number: + problems.append("number required = false") + + if problems: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) password policy is weak: " + f"{'; '.join(problems)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) enforces a strong password " + "policy (min length, uppercase, lowercase, and number)." + ) + findings.append(report) + return findings diff --git a/prowler/providers/lovable/services/apps/apps_client.py b/prowler/providers/lovable/services/apps/apps_client.py new file mode 100644 index 0000000000..8d69bdb5d3 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.lovable.services.apps.apps_service import Apps + +apps_client = Apps(Provider.get_global_provider()) diff --git a/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/__init__.py b/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.metadata.json b/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.metadata.json new file mode 100644 index 0000000000..bdbc6f7aac --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "lovable", + "CheckID": "apps_pre_publication_security_review_completed", + "CheckTitle": "Lovable security review must be completed before publishing", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "LovableApp", + "ResourceGroup": "governance", + "Description": "Lovable provides a built-in 'Review Security' workflow that scans for missing RLS, exposed secrets, missing auth, and similar issues. Apps must run this review and resolve all findings before publishing.", + "Risk": "Publishing without running the security review allows apps with missing RLS, exposed service-role keys, or unauthenticated Edge Functions to reach the public internet. The historical Lovable incident wave was driven exactly by skipped pre-publication reviews.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://meetcyber.net/security-best-practices-for-lovable-apps-2026-be0350cc87e1" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the Lovable dashboard for the project\n2. Click Review Security\n3. Resolve every reported finding (RLS, auth, secrets, etc.)\n4. Re-run the review until it returns zero findings\n5. Republish only after the review passes cleanly", + "Terraform": "" + }, + "Recommendation": { + "Text": "Run Lovable's Review Security workflow as the final gate before publishing every app, and re-run it any time you add a feature, change auth, or modify data access.", + "Url": "https://hub.prowler.com/check/apps_pre_publication_security_review_completed" + } + }, + "Categories": [ + "vulnerabilities", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "apps_supabase_rls_enabled_on_all_tables", + "apps_no_hardcoded_secrets_in_frontend_bundle" + ], + "Notes": "" +} diff --git a/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.py b/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.py new file mode 100644 index 0000000000..45fab23e3a --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_pre_publication_security_review_completed/apps_pre_publication_security_review_completed.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLovable +from prowler.providers.lovable.services.apps.apps_client import apps_client + + +class apps_pre_publication_security_review_completed(Check): + """Lovable's built-in 'Review Security' check must have been run with no + open findings before an app is published.""" + + def execute(self) -> list[CheckReportLovable]: + findings: list[CheckReportLovable] = [] + for app in apps_client.apps.values(): + report = CheckReportLovable(metadata=self.metadata(), resource=app) + + if not app.is_published: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) is not yet published; " + "security review is not gating." + ) + elif not app.security_review_run: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) was published without running " + "Lovable's built-in security review." + ) + elif app.security_review_findings > 0: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) has " + f"{app.security_review_findings} unresolved security review " + f"finding(s) (last run: {app.security_review_last_run})." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) passed Lovable's built-in " + f"security review (last run: {app.security_review_last_run})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/lovable/services/apps/apps_service.py b/prowler/providers/lovable/services/apps/apps_service.py new file mode 100644 index 0000000000..1ac7d1d654 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_service.py @@ -0,0 +1,149 @@ +"""Apps service. + +A Lovable "app" is what the platform calls a project: an AI-generated web +app that is composed of a frontend bundle, optional Supabase backend, and an +optional published URL. This service hydrates the app inventory used by every +check in the provider. +""" + +from typing import Optional + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.lovable.lib.service.service import LovableService + + +class LovableApp(BaseModel): + """A single Lovable project / published app.""" + + id: str + name: str = "" + slug: str = "" + workspace_id: str = "" + visibility: str = "unknown" # public | workspace | private | unknown + is_published: bool = False + published_url: Optional[str] = None + + # Pre-publication security review + security_review_run: bool = False + security_review_findings: int = 0 + security_review_last_run: Optional[str] = None + + # Authentication & user management + auth_enabled: bool = False + captcha_enabled: bool = False + password_min_length: int = 0 + password_requires_uppercase: bool = False + password_requires_lowercase: bool = False + password_requires_number: bool = False + password_requires_symbol: bool = False + auth_rate_limit_enabled: bool = False + + # Supabase backing + has_supabase_backing: bool = False + supabase_project_ref: Optional[str] = None + rls_enabled_on_all_tables: bool = True + tables_without_rls: list[str] = Field(default_factory=list) + + # Edge Functions + edge_functions: list[str] = Field(default_factory=list) + edge_functions_with_auth: list[str] = Field(default_factory=list) + + # Storage + storage_buckets_public: list[str] = Field(default_factory=list) + storage_buckets_private: list[str] = Field(default_factory=list) + + tags: dict = Field(default_factory=dict) + + +class Apps(LovableService): + """Inventory of Lovable apps (projects).""" + + def __init__(self, provider): + super().__init__("Apps", provider) + self.apps: dict[str, LovableApp] = {} + self._fetch_apps() + + def _fetch_apps(self) -> None: + try: + payload = self._paginate("/projects", key="projects") + workspace_id = self._workspace_id or ( + self.provider.identity.workspace.id + if self.provider.identity.workspace + else "" + ) + + project_filter = self.provider.filter_projects + + for raw in payload: + project_id = raw.get("id") + if not project_id: + continue + if project_filter and not ( + project_id in project_filter + or raw.get("slug") in project_filter + or raw.get("name") in project_filter + ): + continue + + self.apps[project_id] = self._build_app(raw, workspace_id) + + # Allow operator-supplied URLs even when the API has no apps for us. + for url in self.provider.published_app_urls: + synthetic_id = f"manual::{url}" + self.apps[synthetic_id] = LovableApp( + id=synthetic_id, + name=url, + slug=url, + workspace_id=workspace_id, + visibility="public", + is_published=True, + published_url=url, + ) + + logger.info(f"Apps - Loaded {len(self.apps)} Lovable app(s).") + except Exception as error: + logger.error( + f"Apps - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _build_app(self, raw: dict, workspace_id: str) -> LovableApp: + """Map a raw Lovable Cloud project payload to LovableApp.""" + raw.get("security") or {} + auth = raw.get("auth") or {} + password = (auth.get("password_policy") or {}) if isinstance(auth, dict) else {} + supabase = raw.get("supabase") or {} + review = raw.get("security_review") or {} + + return LovableApp( + id=raw.get("id", ""), + name=raw.get("name") or raw.get("slug") or raw.get("id", ""), + slug=raw.get("slug", ""), + workspace_id=raw.get("workspace_id") or workspace_id, + visibility=(raw.get("visibility") or "unknown").lower(), + is_published=bool(raw.get("is_published") or raw.get("published_url")), + published_url=raw.get("published_url"), + security_review_run=bool(review.get("last_run_at")), + security_review_findings=int(review.get("open_findings", 0) or 0), + security_review_last_run=review.get("last_run_at"), + auth_enabled=bool(auth.get("enabled")), + captcha_enabled=bool(auth.get("captcha_enabled")), + password_min_length=int(password.get("min_length", 0) or 0), + password_requires_uppercase=bool(password.get("requires_uppercase")), + password_requires_lowercase=bool(password.get("requires_lowercase")), + password_requires_number=bool(password.get("requires_number")), + password_requires_symbol=bool(password.get("requires_symbol")), + auth_rate_limit_enabled=bool(auth.get("rate_limit_enabled")), + has_supabase_backing=bool(supabase.get("project_ref")), + supabase_project_ref=supabase.get("project_ref"), + rls_enabled_on_all_tables=bool(supabase.get("rls_all_tables", True)), + tables_without_rls=list(supabase.get("tables_without_rls", []) or []), + edge_functions=list(supabase.get("edge_functions", []) or []), + edge_functions_with_auth=list( + supabase.get("edge_functions_with_auth", []) or [] + ), + storage_buckets_public=list(supabase.get("buckets_public", []) or []), + storage_buckets_private=list(supabase.get("buckets_private", []) or []), + tags=raw.get("tags") or {}, + ) diff --git a/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/__init__.py b/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.metadata.json b/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.metadata.json new file mode 100644 index 0000000000..2a621c5c38 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "lovable", + "CheckID": "apps_supabase_edge_functions_authenticated", + "CheckTitle": "Supabase Edge Functions must enforce authentication", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "supabase", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "LovableApp", + "ResourceGroup": "serverless", + "Description": "Edge Functions are the server-side boundary for Lovable apps. Authentication, authorization, and validation must be enforced server-side in every Edge Function, not in the browser.", + "Risk": "Unauthenticated Edge Functions let attackers invoke business logic, read protected data, or modify resources by simply calling the function URL. Lovable's guidance is explicit: 'critical behavior never reaches the browser and every request is evaluated consistently'.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://supabase.com/docs/guides/functions/auth" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the Supabase dashboard for the project\n2. For each Edge Function, validate the JWT and reject requests with no Authorization header\n3. Check the user's role / permissions before performing privileged actions\n4. Add automated tests that hit the function without a token and assert HTTP 401", + "Terraform": "" + }, + "Recommendation": { + "Text": "Verify the user session in every Edge Function using supabase-js getUser(), check role/permission claims, and never rely on frontend-only authorization. Treat any function that can mutate data as a sensitive endpoint.", + "Url": "https://hub.prowler.com/check/apps_supabase_edge_functions_authenticated" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "apps_supabase_rls_enabled_on_all_tables" + ], + "Notes": "" +} diff --git a/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.py b/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.py new file mode 100644 index 0000000000..c1b88bffbd --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_supabase_edge_functions_authenticated/apps_supabase_edge_functions_authenticated.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, CheckReportLovable +from prowler.providers.lovable.services.apps.apps_client import apps_client + + +class apps_supabase_edge_functions_authenticated(Check): + """Every Supabase Edge Function backing a Lovable app must enforce auth.""" + + def execute(self) -> list[CheckReportLovable]: + findings: list[CheckReportLovable] = [] + for app in apps_client.apps.values(): + report = CheckReportLovable(metadata=self.metadata(), resource=app) + + if not app.has_supabase_backing or not app.edge_functions: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) has no Edge Functions to check." + ) + findings.append(report) + continue + + unauth = [ + fn + for fn in app.edge_functions + if fn not in app.edge_functions_with_auth + ] + if unauth: + report.status = "FAIL" + report.status_extended = ( + f"App {app.name} ({app.id}) has Edge Function(s) without " + f"authentication enforcement: {', '.join(unauth)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"App {app.name} ({app.id}) has authentication enforced on " + f"all {len(app.edge_functions)} Edge Function(s)." + ) + findings.append(report) + return findings diff --git a/prowler/providers/lovable/services/apps/apps_supabase_rls_enabled_on_all_tables/__init__.py b/prowler/providers/lovable/services/apps/apps_supabase_rls_enabled_on_all_tables/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/lovable/services/apps/apps_supabase_rls_enabled_on_all_tables/apps_supabase_rls_enabled_on_all_tables.metadata.json b/prowler/providers/lovable/services/apps/apps_supabase_rls_enabled_on_all_tables/apps_supabase_rls_enabled_on_all_tables.metadata.json new file mode 100644 index 0000000000..40cd3e0937 --- /dev/null +++ b/prowler/providers/lovable/services/apps/apps_supabase_rls_enabled_on_all_tables/apps_supabase_rls_enabled_on_all_tables.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "lovable", + "CheckID": "apps_supabase_rls_enabled_on_all_tables", + "CheckTitle": "Supabase tables backing Lovable apps must have RLS enabled", + "CheckType": [], + "ServiceName": "apps", + "SubServiceName": "supabase", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "LovableApp", + "ResourceGroup": "database", + "Description": "Lovable apps backed by Supabase must enable Row Level Security on every table containing user data, so that even if frontend or backend logic fails, the database itself enforces tenant isolation.", + "Risk": "Tables without RLS allow any authenticated client to read or modify any row using the public anon key. This is the single most common cause of catastrophic Lovable app data leaks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://supabase.com/docs/guides/database/postgres/row-level-security", + "https://meetcyber.net/security-best-practices-for-lovable-apps-2026-be0350cc87e1" + ], + "Remediation": { + "Code": { + "CLI": "ALTER TABLE