mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-13 15:50:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 077a4e7dab |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class Provider(str, Enum):
|
||||
OPENSTACK = "openstack"
|
||||
IMAGE = "image"
|
||||
VERCEL = "vercel"
|
||||
LOVABLE = "lovable"
|
||||
|
||||
|
||||
# Compliance
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)."
|
||||
),
|
||||
)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+39
@@ -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": ""
|
||||
}
|
||||
+30
@@ -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
|
||||
+39
@@ -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": ""
|
||||
}
|
||||
+31
@@ -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
|
||||
+36
@@ -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": ""
|
||||
}
|
||||
+48
@@ -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
|
||||
@@ -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())
|
||||
+40
@@ -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": ""
|
||||
}
|
||||
+40
@@ -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
|
||||
@@ -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 {},
|
||||
)
|
||||
+39
@@ -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": ""
|
||||
}
|
||||
+39
@@ -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
|
||||
+40
@@ -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 <table> ENABLE ROW LEVEL SECURITY;\nCREATE POLICY <name> ON <table> FOR SELECT USING (auth.uid() = user_id);",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the Supabase dashboard for the project\n2. Navigate to Authentication > Policies\n3. Enable RLS on every table containing user data\n4. Add policies that scope SELECT/INSERT/UPDATE/DELETE to the appropriate user, team, or organization\n5. Test that an unauthenticated session cannot read sensitive data",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable Row Level Security on every Supabase table and add policies that match your access model (per-user, per-team, per-org). Treat RLS as the final layer of defence behind frontend and backend checks.",
|
||||
"Url": "https://hub.prowler.com/check/apps_supabase_rls_enabled_on_all_tables"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"apps_pre_publication_security_review_completed"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.apps.apps_client import apps_client
|
||||
|
||||
|
||||
class apps_supabase_rls_enabled_on_all_tables(Check):
|
||||
"""RLS must be enabled on every table in the Supabase backing the app."""
|
||||
|
||||
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:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) does not have a known Supabase "
|
||||
"backend; verify data-access posture manually."
|
||||
)
|
||||
elif app.rls_enabled_on_all_tables and not app.tables_without_rls:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) has Row Level Security enabled "
|
||||
"on every Supabase table."
|
||||
)
|
||||
else:
|
||||
without_rls = ", ".join(app.tables_without_rls) or "unspecified"
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) has Supabase tables without RLS "
|
||||
f"policies: {without_rls}."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "apps_supabase_storage_buckets_not_public",
|
||||
"CheckTitle": "Supabase storage buckets backing Lovable apps must not be public",
|
||||
"CheckType": [],
|
||||
"ServiceName": "apps",
|
||||
"SubServiceName": "supabase",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "LovableApp",
|
||||
"ResourceGroup": "storage",
|
||||
"Description": "Lovable apps must keep Supabase storage buckets that hold user content private. Only buckets serving truly public assets (logos, marketing images) should be marked public.",
|
||||
"Risk": "Public buckets allow anyone with the URL to enumerate and download files, which can leak user uploads, PII, and private documents.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://supabase.com/docs/guides/storage/security/access-control"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the Supabase dashboard\n2. Navigate to Storage > Buckets\n3. For every bucket containing user content, switch Visibility to Private\n4. Add storage RLS policies that scope reads/writes to the owning user\n5. Generate signed URLs for legitimate downloads",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Default Supabase storage buckets to Private and use storage RLS plus signed URLs for downloads. Only keep buckets public if they serve assets that anyone may legitimately access.",
|
||||
"Url": "https://hub.prowler.com/check/apps_supabase_storage_buckets_not_public"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"apps_supabase_rls_enabled_on_all_tables"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.apps.apps_client import apps_client
|
||||
|
||||
|
||||
class apps_supabase_storage_buckets_not_public(Check):
|
||||
"""User-content Supabase storage buckets must not be marked public."""
|
||||
|
||||
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:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) has no Supabase storage to check."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
if app.storage_buckets_public:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) has public Supabase storage "
|
||||
f"bucket(s): {', '.join(app.storage_buckets_public)}. "
|
||||
"Confirm these intentionally serve only public assets and "
|
||||
"no user uploads."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) has no public Supabase "
|
||||
"storage buckets."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "apps_workspace_visibility_for_internal_apps",
|
||||
"CheckTitle": "Internal Lovable apps must use workspace visibility",
|
||||
"CheckType": [],
|
||||
"ServiceName": "apps",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "LovableApp",
|
||||
"ResourceGroup": "governance",
|
||||
"Description": "Lovable apps that are not intended for public consumption must be set to workspace visibility so that authentication is required and the app is not exposed to anonymous visitors on the public internet.",
|
||||
"Risk": "Apps tagged as internal but published with public visibility expose admin tooling, internal dashboards, or staging environments to anyone on the internet. Attackers can reach login screens, perform credential stuffing, or exploit weakly-protected admin functionality.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.lovable.dev/tips-tricks/security-best-practices#workspace-security"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the Lovable dashboard\n2. Select the app and navigate to Settings > Project access\n3. Change the visibility from Public to Workspace\n4. Confirm only intended workspace members retain access\n5. Re-publish the app",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set internal Lovable apps to Workspace visibility, require authentication, and audit workspace member access regularly. Reserve Public visibility for apps that are intentionally exposed to the internet.",
|
||||
"Url": "https://hub.prowler.com/check/apps_workspace_visibility_for_internal_apps"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.apps.apps_client import apps_client
|
||||
|
||||
|
||||
class apps_workspace_visibility_for_internal_apps(Check):
|
||||
"""Internal Lovable apps must use workspace visibility, not public.
|
||||
|
||||
Lovable best practice: apps that aren't intended for public use should be
|
||||
set to "workspace" visibility so authentication is required and external
|
||||
visitors cannot reach them.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportLovable]:
|
||||
findings: list[CheckReportLovable] = []
|
||||
for app in apps_client.apps.values():
|
||||
report = CheckReportLovable(metadata=self.metadata(), resource=app)
|
||||
|
||||
is_internal = bool(app.tags) and (
|
||||
app.tags.get("environment") in {"internal", "staging", "dev"}
|
||||
or app.tags.get("internal") is True
|
||||
)
|
||||
|
||||
if not is_internal and not app.is_published:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) is not published; visibility "
|
||||
"controls are not applicable yet."
|
||||
)
|
||||
elif app.visibility == "workspace":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) is restricted to workspace "
|
||||
"visibility."
|
||||
)
|
||||
elif app.visibility == "public":
|
||||
if is_internal:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) is tagged internal but "
|
||||
"is published with public visibility."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) is intentionally public; "
|
||||
"ensure auth is enforced server-side."
|
||||
)
|
||||
else:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"App {app.name} ({app.id}) visibility is "
|
||||
f"'{app.visibility}'. Review in the Lovable dashboard."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "published_app_no_secrets_in_frontend_bundle",
|
||||
"CheckTitle": "Published Lovable apps must not bundle secrets into the frontend",
|
||||
"CheckType": [],
|
||||
"ServiceName": "published",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "LovablePublishedApp",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Published Lovable apps must not include service-role keys, third-party API tokens, or private keys in the JavaScript bundle delivered to the browser. The browser bundle is fully public and any secret bundled into it must be considered compromised.",
|
||||
"Risk": "A leaked Supabase service-role key bypasses Row Level Security entirely, granting attackers full read/write access to the database. Leaked third-party API keys (OpenAI, Stripe, AWS, GitHub, ...) can be abused for fraudulent charges, data exfiltration, or further compromise.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://meetcyber.net/security-best-practices-for-lovable-apps-2026-be0350cc87e1"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Remove the secret from the frontend code and the build config\n2. Rotate the leaked secret immediately at the upstream provider\n3. Move the call that needed the secret into a Supabase Edge Function\n4. Inject only the public anon key into the frontend\n5. Re-publish the app and re-scan",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Treat the frontend bundle as fully public. Keep the Supabase service-role key and every third-party API key inside Edge Functions. Use the public anon key in the browser only.",
|
||||
"Url": "https://hub.prowler.com/check/published_app_no_secrets_in_frontend_bundle"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets",
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"apps_pre_publication_security_review_completed"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
from collections import Counter
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.published.published_client import (
|
||||
published_client,
|
||||
)
|
||||
|
||||
|
||||
class published_app_no_secrets_in_frontend_bundle(Check):
|
||||
"""No service-role keys or third-party secrets must be bundled into the
|
||||
published Lovable app's frontend JavaScript."""
|
||||
|
||||
def execute(self) -> list[CheckReportLovable]:
|
||||
findings: list[CheckReportLovable] = []
|
||||
for inspection in published_client.inspections.values():
|
||||
report = CheckReportLovable(metadata=self.metadata(), resource=inspection)
|
||||
if not inspection.reachable:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not reach published app {inspection.app_name}; "
|
||||
"could not scan frontend bundle for secrets."
|
||||
)
|
||||
elif not inspection.bundles_inspected:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not locate JS bundles for {inspection.app_name}; "
|
||||
"frontend secret scan was skipped."
|
||||
)
|
||||
elif inspection.leaked_secrets:
|
||||
counts = Counter(s["type"] for s in inspection.leaked_secrets)
|
||||
summary = ", ".join(
|
||||
f"{count} {label}" for label, count in counts.items()
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} bundles secret-like "
|
||||
f"values in its frontend JavaScript ({summary}). Anything "
|
||||
"shipped to the browser must be considered compromised."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} did not match any "
|
||||
"high-confidence secret patterns in scanned JS bundles "
|
||||
f"({len(inspection.bundles_inspected)} bundle(s))."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "published_app_security_headers_configured",
|
||||
"CheckTitle": "Published Lovable apps must set the required HTTP security headers",
|
||||
"CheckType": [],
|
||||
"ServiceName": "published",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "LovablePublishedApp",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Published Lovable apps must respond with HTTP security headers (Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) to mitigate XSS, clickjacking, MIME sniffing, downgrade, and referrer-leak attacks.",
|
||||
"Risk": "Missing security headers allow common browser-based attacks such as clickjacking, MIME-sniffing, cross-site scripting injection via inline scripts, downgrade to HTTP, and leakage of sensitive URLs through Referer headers.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://owasp.org/www-project-secure-headers/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Configure security headers at the edge or Edge Function gateway\n2. Set Content-Security-Policy with a strict default-src and explicit allowlists\n3. Set Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n4. Set X-Content-Type-Options: nosniff\n5. Set X-Frame-Options: DENY (or use frame-ancestors in CSP)\n6. Set Referrer-Policy: strict-origin-when-cross-origin",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Add a baseline security header configuration to every published Lovable app and verify it after deploys with an external scanner.",
|
||||
"Url": "https://hub.prowler.com/check/published_app_security_headers_configured"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"published_app_strict_csp_configured"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.published.published_client import (
|
||||
published_client,
|
||||
)
|
||||
|
||||
|
||||
class published_app_security_headers_configured(Check):
|
||||
"""All required HTTP security headers must be set on the published app."""
|
||||
|
||||
def execute(self) -> list[CheckReportLovable]:
|
||||
findings: list[CheckReportLovable] = []
|
||||
for inspection in published_client.inspections.values():
|
||||
report = CheckReportLovable(metadata=self.metadata(), resource=inspection)
|
||||
if not inspection.reachable:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not reach published app {inspection.app_name}; "
|
||||
"verify security headers manually."
|
||||
)
|
||||
elif inspection.missing_security_headers:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} is missing required "
|
||||
f"security headers: {', '.join(inspection.missing_security_headers)}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} returns the required "
|
||||
"security headers (CSP, HSTS, X-Content-Type-Options, "
|
||||
"X-Frame-Options, Referrer-Policy)."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "published_app_strict_csp_configured",
|
||||
"CheckTitle": "Published Lovable apps must use a strict Content-Security-Policy",
|
||||
"CheckType": [],
|
||||
"ServiceName": "published",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "LovablePublishedApp",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Published Lovable apps must serve a strict Content-Security-Policy: a default-src directive must be set and the policy must not use wildcards or 'unsafe-inline'/'unsafe-eval' for script execution.",
|
||||
"Risk": "A permissive CSP undermines XSS mitigation: 'unsafe-inline' lets attacker-injected payloads execute, wildcard sources allow loading scripts from any origin, and a missing default-src leaves directives unconstrained.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://content-security-policy.com/",
|
||||
"https://web.dev/articles/strict-csp"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Define a default-src directive (e.g., default-src 'self')\n2. Replace 'unsafe-inline' / 'unsafe-eval' with nonces or hashes\n3. Restrict script-src, style-src, frame-ancestors, and connect-src to known origins\n4. Test the CSP in report-only mode before enforcing\n5. Monitor CSP violation reports",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Adopt a strict CSP based on the W3C recommended pattern (nonces or hashes) and avoid 'unsafe-inline' / 'unsafe-eval'. Roll out via Content-Security-Policy-Report-Only first, then enforce.",
|
||||
"Url": "https://hub.prowler.com/check/published_app_strict_csp_configured"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [
|
||||
"published_app_security_headers_configured"
|
||||
],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.published.published_client import (
|
||||
published_client,
|
||||
)
|
||||
|
||||
|
||||
class published_app_strict_csp_configured(Check):
|
||||
"""Content-Security-Policy must define default-src and exclude unsafe-inline / unsafe-eval / wildcards."""
|
||||
|
||||
def execute(self) -> list[CheckReportLovable]:
|
||||
findings: list[CheckReportLovable] = []
|
||||
for inspection in published_client.inspections.values():
|
||||
report = CheckReportLovable(metadata=self.metadata(), resource=inspection)
|
||||
if not inspection.reachable:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not reach published app {inspection.app_name}; "
|
||||
"verify CSP manually."
|
||||
)
|
||||
elif "content-security-policy" not in inspection.headers:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} does not return a "
|
||||
"Content-Security-Policy header."
|
||||
)
|
||||
elif inspection.has_strict_csp:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} returns a strict "
|
||||
"Content-Security-Policy."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} returns a permissive "
|
||||
"Content-Security-Policy (uses wildcard, 'unsafe-inline', "
|
||||
"or 'unsafe-eval', or omits default-src)."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "lovable",
|
||||
"CheckID": "published_app_uses_https",
|
||||
"CheckTitle": "Published Lovable apps must be served over HTTPS",
|
||||
"CheckType": [],
|
||||
"ServiceName": "published",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "LovablePublishedApp",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Every published Lovable app must be served exclusively over HTTPS so that user credentials, session tokens, and application data are protected from network eavesdropping and tampering.",
|
||||
"Risk": "An app served over plain HTTP exposes credentials, JWTs, and user data to passive sniffing and active man-in-the-middle attacks. Modern browsers also block many security-relevant features on insecure origins.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://web.dev/why-https-matters/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Confirm the custom domain has a valid TLS certificate\n2. Force HTTPS redirects at the edge\n3. Enable HSTS with includeSubDomains and preload\n4. Disable any HTTP-only listeners",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Always serve published apps over HTTPS, redirect HTTP to HTTPS, and enable HSTS to prevent downgrade attacks.",
|
||||
"Url": "https://hub.prowler.com/check/published_app_uses_https"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption",
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"published_app_security_headers_configured"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
from prowler.lib.check.models import Check, CheckReportLovable
|
||||
from prowler.providers.lovable.services.published.published_client import (
|
||||
published_client,
|
||||
)
|
||||
|
||||
|
||||
class published_app_uses_https(Check):
|
||||
"""The published Lovable app must be served over HTTPS."""
|
||||
|
||||
def execute(self) -> list[CheckReportLovable]:
|
||||
findings: list[CheckReportLovable] = []
|
||||
for inspection in published_client.inspections.values():
|
||||
report = CheckReportLovable(metadata=self.metadata(), resource=inspection)
|
||||
if not inspection.reachable:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not reach published app {inspection.app_name} at "
|
||||
f"{inspection.published_url}; verify HTTPS posture manually."
|
||||
)
|
||||
elif inspection.is_https:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} is served over HTTPS."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Published app {inspection.app_name} is reachable over "
|
||||
f"plain HTTP at {inspection.published_url}."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.lovable.services.published.published_service import Published
|
||||
|
||||
published_client = Published(Provider.get_global_provider())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Published-app HTTP inspector.
|
||||
|
||||
Fetches each published Lovable app's HTML and a sample of its bundled JS
|
||||
to evaluate runtime security signals that the Cloud API does not expose:
|
||||
|
||||
- HTTP security headers (CSP, HSTS, X-Frame-Options, ...)
|
||||
- HTTPS / HSTS posture
|
||||
- Secrets accidentally bundled into the frontend
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.lovable.config import (
|
||||
LOVABLE_DEFAULT_TIMEOUT,
|
||||
LOVABLE_USER_AGENT,
|
||||
REQUIRED_SECURITY_HEADERS,
|
||||
SECRET_PATTERNS,
|
||||
)
|
||||
from prowler.providers.lovable.lib.service.service import LovableService
|
||||
|
||||
# Cap how much of each bundle we read; secret scan only needs a sample.
|
||||
MAX_BUNDLE_BYTES = 1_500_000
|
||||
MAX_BUNDLES_PER_APP = 5
|
||||
SCRIPT_SRC_REGEX = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.IGNORECASE)
|
||||
COMPILED_SECRET_PATTERNS = tuple((re.compile(p), label) for p, label in SECRET_PATTERNS)
|
||||
|
||||
|
||||
class PublishedAppInspection(BaseModel):
|
||||
"""Result of inspecting a single published app over HTTP."""
|
||||
|
||||
app_id: str
|
||||
app_name: str
|
||||
workspace_id: str = ""
|
||||
published_url: Optional[str] = None
|
||||
|
||||
reachable: bool = False
|
||||
is_https: bool = False
|
||||
status_code: Optional[int] = None
|
||||
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
missing_security_headers: list[str] = Field(default_factory=list)
|
||||
has_strict_csp: bool = False
|
||||
|
||||
bundles_inspected: list[str] = Field(default_factory=list)
|
||||
leaked_secrets: list[dict] = Field(default_factory=list)
|
||||
|
||||
# required by CheckReportLovable
|
||||
name: str = ""
|
||||
id: str = ""
|
||||
|
||||
|
||||
class Published(LovableService):
|
||||
"""Live HTTP inspection of published Lovable apps."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Published", provider)
|
||||
self.inspections: dict[str, PublishedAppInspection] = {}
|
||||
|
||||
from prowler.providers.lovable.services.apps.apps_client import apps_client
|
||||
|
||||
published_apps = [app for app in apps_client.apps.values() if app.is_published]
|
||||
self.__threading_call__(self._inspect_app, published_apps)
|
||||
|
||||
def _inspect_app(self, app) -> None:
|
||||
if not app.published_url:
|
||||
return
|
||||
|
||||
inspection = PublishedAppInspection(
|
||||
app_id=app.id,
|
||||
app_name=app.name,
|
||||
workspace_id=app.workspace_id,
|
||||
published_url=app.published_url,
|
||||
id=app.id,
|
||||
name=app.name,
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._fetch(app.published_url)
|
||||
inspection.reachable = True
|
||||
inspection.status_code = response.status_code
|
||||
inspection.is_https = urlparse(app.published_url).scheme == "https"
|
||||
inspection.headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
inspection.missing_security_headers = [
|
||||
header
|
||||
for header in REQUIRED_SECURITY_HEADERS
|
||||
if header not in inspection.headers
|
||||
]
|
||||
inspection.has_strict_csp = self._is_strict_csp(
|
||||
inspection.headers.get("content-security-policy", "")
|
||||
)
|
||||
|
||||
bundle_urls = self._discover_bundles(
|
||||
base_url=app.published_url, html=response.text
|
||||
)
|
||||
inspection.bundles_inspected = bundle_urls
|
||||
|
||||
for bundle_url in bundle_urls:
|
||||
inspection.leaked_secrets.extend(self._scan_bundle(bundle_url))
|
||||
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"Published - Could not inspect {app.published_url}: "
|
||||
f"{error.__class__.__name__}: {error}"
|
||||
)
|
||||
|
||||
self.inspections[app.id] = inspection
|
||||
|
||||
def _fetch(self, url: str) -> requests.Response:
|
||||
return requests.get(
|
||||
url,
|
||||
timeout=LOVABLE_DEFAULT_TIMEOUT,
|
||||
headers={"User-Agent": LOVABLE_USER_AGENT},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def _discover_bundles(self, base_url: str, html: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for match in SCRIPT_SRC_REGEX.finditer(html or ""):
|
||||
src = match.group(1)
|
||||
if not src.endswith(".js") and "/assets/" not in src:
|
||||
continue
|
||||
full = urljoin(base_url, src)
|
||||
if full not in urls:
|
||||
urls.append(full)
|
||||
if len(urls) >= MAX_BUNDLES_PER_APP:
|
||||
break
|
||||
return urls
|
||||
|
||||
def _scan_bundle(self, url: str) -> list[dict]:
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=LOVABLE_DEFAULT_TIMEOUT,
|
||||
headers={"User-Agent": LOVABLE_USER_AGENT},
|
||||
stream=True,
|
||||
)
|
||||
content = response.raw.read(MAX_BUNDLE_BYTES, decode_content=True)
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
findings: list[dict] = []
|
||||
for pattern, label in COMPILED_SECRET_PATTERNS:
|
||||
for match in pattern.finditer(text):
|
||||
findings.append(
|
||||
{
|
||||
"type": label,
|
||||
"bundle": url,
|
||||
"match_preview": _redact(match.group(0)),
|
||||
}
|
||||
)
|
||||
if len(findings) > 20:
|
||||
break
|
||||
if len(findings) > 20:
|
||||
break
|
||||
return findings
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
f"Published - Bundle scan failed for {url}: "
|
||||
f"{error.__class__.__name__}: {error}"
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _is_strict_csp(csp: str) -> bool:
|
||||
if not csp:
|
||||
return False
|
||||
csp_lower = csp.lower()
|
||||
if "default-src" not in csp_lower:
|
||||
return False
|
||||
# Reject the most dangerous wildcards / inline allowances.
|
||||
if "'unsafe-inline'" in csp_lower or "'unsafe-eval'" in csp_lower:
|
||||
return False
|
||||
if "default-src *" in csp_lower or "script-src *" in csp_lower:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _redact(value: str) -> str:
|
||||
"""Keep the first 4 and last 4 chars; redact the middle."""
|
||||
if len(value) <= 12:
|
||||
return "***"
|
||||
return f"{value[:4]}...{value[-4:]}"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Reusable Lovable test fixtures."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from prowler.providers.lovable.models import (
|
||||
LovableIdentityInfo,
|
||||
LovableSession,
|
||||
LovableWorkspaceInfo,
|
||||
)
|
||||
|
||||
API_TOKEN = "lovable_test_token_xxxxxxxxxxxxxxxxxxxxxx"
|
||||
WORKSPACE_ID = "ws_test_workspace"
|
||||
WORKSPACE_NAME = "Test Workspace"
|
||||
WORKSPACE_SLUG = "test-workspace"
|
||||
USER_ID = "user_test_id"
|
||||
USER_EMAIL = "test@example.com"
|
||||
USERNAME = "testuser"
|
||||
|
||||
|
||||
def make_session(workspace_id: str = WORKSPACE_ID) -> LovableSession:
|
||||
return LovableSession(
|
||||
api_token=API_TOKEN,
|
||||
workspace_id=workspace_id,
|
||||
http_session=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def make_identity() -> LovableIdentityInfo:
|
||||
workspace = LovableWorkspaceInfo(
|
||||
id=WORKSPACE_ID,
|
||||
name=WORKSPACE_NAME,
|
||||
slug=WORKSPACE_SLUG,
|
||||
plan="pro",
|
||||
)
|
||||
return LovableIdentityInfo(
|
||||
user_id=USER_ID,
|
||||
username=USERNAME,
|
||||
email=USER_EMAIL,
|
||||
workspace=workspace,
|
||||
workspaces=[workspace],
|
||||
)
|
||||
|
||||
|
||||
def make_provider() -> MagicMock:
|
||||
"""Build a stub provider sufficient for service constructors."""
|
||||
provider = MagicMock()
|
||||
provider.session = make_session()
|
||||
provider.identity = make_identity()
|
||||
provider.audit_config = {"max_retries": 0}
|
||||
provider.fixer_config = {}
|
||||
provider.filter_projects = None
|
||||
provider.published_app_urls = []
|
||||
provider.supabase_access_token = None
|
||||
return provider
|
||||
|
||||
|
||||
def set_mocked_lovable_provider() -> MagicMock:
|
||||
"""Alias used by check tests, mirroring the Vercel test pattern."""
|
||||
return make_provider()
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.common.models import Connection
|
||||
from prowler.providers.lovable.exceptions.exceptions import (
|
||||
LovableAuthenticationError,
|
||||
LovableCredentialsError,
|
||||
)
|
||||
from prowler.providers.lovable.lovable_provider import LovableProvider
|
||||
from prowler.providers.lovable.models import LovableSession
|
||||
from tests.providers.lovable.lovable_fixtures import API_TOKEN, WORKSPACE_ID
|
||||
|
||||
|
||||
class TestLovableProviderSetupSession:
|
||||
def test_setup_session_with_env_var(self):
|
||||
with mock.patch.dict(os.environ, {"LOVABLE_API_TOKEN": API_TOKEN}, clear=True):
|
||||
session = LovableProvider.setup_session()
|
||||
|
||||
assert isinstance(session, LovableSession)
|
||||
assert session.api_token == API_TOKEN
|
||||
assert session.http_session is not None
|
||||
|
||||
def test_setup_session_with_arguments(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
session = LovableProvider.setup_session(
|
||||
api_token=API_TOKEN, workspace_id=WORKSPACE_ID
|
||||
)
|
||||
assert session.api_token == API_TOKEN
|
||||
assert session.workspace_id == WORKSPACE_ID
|
||||
|
||||
def test_setup_session_no_credentials_raises(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("LOVABLE_API_TOKEN", None)
|
||||
with pytest.raises(LovableCredentialsError):
|
||||
LovableProvider.setup_session()
|
||||
|
||||
def test_setup_session_workspace_from_env(self):
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"LOVABLE_API_TOKEN": API_TOKEN, "LOVABLE_WORKSPACE_ID": WORKSPACE_ID},
|
||||
clear=True,
|
||||
):
|
||||
session = LovableProvider.setup_session()
|
||||
assert session.workspace_id == WORKSPACE_ID
|
||||
|
||||
|
||||
class TestLovableProviderTestConnection:
|
||||
def test_test_connection_success(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.setup_session"
|
||||
) as session_mock,
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.validate_credentials"
|
||||
) as validate_mock,
|
||||
):
|
||||
session_mock.return_value = MagicMock()
|
||||
validate_mock.return_value = None
|
||||
|
||||
result = LovableProvider.test_connection(api_token=API_TOKEN)
|
||||
|
||||
assert isinstance(result, Connection)
|
||||
assert result.is_connected is True
|
||||
|
||||
def test_test_connection_no_credentials_returns_error(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
result = LovableProvider.test_connection(raise_on_exception=False)
|
||||
|
||||
assert isinstance(result, Connection)
|
||||
assert result.is_connected is False
|
||||
assert isinstance(result.error, LovableCredentialsError)
|
||||
|
||||
def test_test_connection_raises_on_invalid_token(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.setup_session"
|
||||
) as session_mock,
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.lovable_provider.LovableProvider.validate_credentials",
|
||||
side_effect=LovableAuthenticationError(file=__file__),
|
||||
),
|
||||
):
|
||||
session_mock.return_value = MagicMock()
|
||||
with pytest.raises(LovableAuthenticationError):
|
||||
LovableProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=True
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.lovable.services.apps.apps_service import LovableApp
|
||||
from tests.providers.lovable.lovable_fixtures import (
|
||||
WORKSPACE_ID,
|
||||
set_mocked_lovable_provider,
|
||||
)
|
||||
|
||||
|
||||
def _make_app(**overrides) -> LovableApp:
|
||||
base = dict(
|
||||
id="app_1",
|
||||
name="App 1",
|
||||
slug="app-1",
|
||||
workspace_id=WORKSPACE_ID,
|
||||
visibility="public",
|
||||
is_published=True,
|
||||
published_url="https://app.lovable.app",
|
||||
has_supabase_backing=True,
|
||||
rls_enabled_on_all_tables=True,
|
||||
tables_without_rls=[],
|
||||
)
|
||||
base.update(overrides)
|
||||
return LovableApp(**base)
|
||||
|
||||
|
||||
class Test_apps_supabase_rls_enabled_on_all_tables:
|
||||
def _run(self, apps_dict):
|
||||
apps_client = mock.MagicMock
|
||||
apps_client.apps = apps_dict
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_lovable_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.services.apps.apps_supabase_rls_enabled_on_all_tables.apps_supabase_rls_enabled_on_all_tables.apps_client",
|
||||
new=apps_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.lovable.services.apps.apps_supabase_rls_enabled_on_all_tables.apps_supabase_rls_enabled_on_all_tables import (
|
||||
apps_supabase_rls_enabled_on_all_tables,
|
||||
)
|
||||
|
||||
check = apps_supabase_rls_enabled_on_all_tables()
|
||||
return check.execute()
|
||||
|
||||
def test_pass_when_all_tables_have_rls(self):
|
||||
app = _make_app(rls_enabled_on_all_tables=True, tables_without_rls=[])
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "Row Level Security" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_tables_missing_rls(self):
|
||||
app = _make_app(
|
||||
rls_enabled_on_all_tables=False,
|
||||
tables_without_rls=["public.users", "public.orders"],
|
||||
)
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "public.users" in findings[0].status_extended
|
||||
assert "public.orders" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_no_supabase_backing(self):
|
||||
app = _make_app(has_supabase_backing=False)
|
||||
findings = self._run({app.id: app})
|
||||
|
||||
assert findings[0].status == "MANUAL"
|
||||
|
||||
def test_no_findings_when_no_apps(self):
|
||||
findings = self._run({})
|
||||
assert findings == []
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.lovable.services.published.published_service import (
|
||||
PublishedAppInspection,
|
||||
)
|
||||
from tests.providers.lovable.lovable_fixtures import set_mocked_lovable_provider
|
||||
|
||||
|
||||
def _inspection(**overrides) -> PublishedAppInspection:
|
||||
base = dict(
|
||||
app_id="app_1",
|
||||
app_name="App 1",
|
||||
workspace_id="ws_1",
|
||||
published_url="https://app.lovable.app",
|
||||
reachable=True,
|
||||
is_https=True,
|
||||
status_code=200,
|
||||
headers={"content-type": "text/html"},
|
||||
bundles_inspected=["https://app.lovable.app/assets/main.js"],
|
||||
leaked_secrets=[],
|
||||
id="app_1",
|
||||
name="App 1",
|
||||
)
|
||||
base.update(overrides)
|
||||
return PublishedAppInspection(**base)
|
||||
|
||||
|
||||
class Test_published_app_no_secrets_in_frontend_bundle:
|
||||
def _run(self, inspections):
|
||||
published_client = mock.MagicMock
|
||||
published_client.inspections = inspections
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_lovable_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.lovable.services.published.published_app_no_secrets_in_frontend_bundle.published_app_no_secrets_in_frontend_bundle.published_client",
|
||||
new=published_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.lovable.services.published.published_app_no_secrets_in_frontend_bundle.published_app_no_secrets_in_frontend_bundle import (
|
||||
published_app_no_secrets_in_frontend_bundle,
|
||||
)
|
||||
|
||||
check = published_app_no_secrets_in_frontend_bundle()
|
||||
return check.execute()
|
||||
|
||||
def test_pass_when_no_secrets_detected(self):
|
||||
inspection = _inspection(leaked_secrets=[])
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_fail_when_supabase_jwt_leaked(self):
|
||||
inspection = _inspection(
|
||||
leaked_secrets=[
|
||||
{
|
||||
"type": "supabase_jwt",
|
||||
"bundle": "https://app.lovable.app/assets/main.js",
|
||||
"match_preview": "eyJh...abcd",
|
||||
}
|
||||
]
|
||||
)
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "1 supabase_jwt" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_app_unreachable(self):
|
||||
inspection = _inspection(reachable=False)
|
||||
findings = self._run({inspection.app_id: inspection})
|
||||
|
||||
assert findings[0].status == "MANUAL"
|
||||
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Lovable provider option in the connect-account flow: provider radio choice, brand badge, workspace UID input, credentials form (Lovable API token plus optional Supabase access token), and findings/overview wiring
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
LovableProviderBadge: () => <span>Lovable</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
LovableProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
@@ -51,6 +52,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
vercel: <VercelProviderBadge width={18} height={18} />,
|
||||
lovable: <LovableProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
LovableProviderBadge: () => <span>Lovable</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
|
||||
@@ -89,6 +89,11 @@ const VercelProviderBadge = lazy(() =>
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const LovableProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.LovableProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
@@ -160,6 +165,10 @@ const PROVIDER_DATA: Record<
|
||||
label: "Vercel",
|
||||
icon: VercelProviderBadge,
|
||||
},
|
||||
lovable: {
|
||||
label: "Lovable",
|
||||
icon: LovableProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
LovableProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
@@ -34,6 +35,7 @@ export const PROVIDER_ICONS = {
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
vercel: VercelProviderBadge,
|
||||
lovable: LovableProviderBadge,
|
||||
} as const;
|
||||
|
||||
interface ProviderIconCellProps {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GoogleWorkspaceProviderBadge } from "./googleworkspace-provider-badge";
|
||||
import { IacProviderBadge } from "./iac-provider-badge";
|
||||
import { ImageProviderBadge } from "./image-provider-badge";
|
||||
import { KS8ProviderBadge } from "./ks8-provider-badge";
|
||||
import { LovableProviderBadge } from "./lovable-provider-badge";
|
||||
import { M365ProviderBadge } from "./m365-provider-badge";
|
||||
import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge";
|
||||
import { OpenStackProviderBadge } from "./openstack-provider-badge";
|
||||
@@ -29,6 +30,7 @@ export {
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
LovableProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
@@ -53,4 +55,5 @@ export const PROVIDER_BADGE_BY_NAME: Record<string, FC<IconSvgProps>> = {
|
||||
Cloudflare: CloudflareProviderBadge,
|
||||
OpenStack: OpenStackProviderBadge,
|
||||
Vercel: VercelProviderBadge,
|
||||
Lovable: LovableProviderBadge,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IconSvgProps } from "@/types";
|
||||
|
||||
export const LovableProviderBadge: React.FC<IconSvgProps> = ({
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 256 256"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="#FF4F8B" rx="60" />
|
||||
<path
|
||||
d="M128 196c-7 0-13.6-2.7-18.6-7.7l-44.7-44.7c-15.6-15.6-15.6-40.9 0-56.6 15.6-15.6 40.9-15.6 56.6 0L128 94.7l6.7-6.7c15.6-15.6 40.9-15.6 56.6 0 15.6 15.6 15.6 40.9 0 56.6l-44.7 44.7c-5 5-11.6 7.7-18.6 7.7Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
LovableProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
@@ -103,6 +104,11 @@ const PROVIDERS = [
|
||||
label: "Vercel",
|
||||
badge: VercelProviderBadge,
|
||||
},
|
||||
{
|
||||
value: "lovable",
|
||||
label: "Lovable",
|
||||
badge: LovableProviderBadge,
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface RadioGroupProviderProps {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
IacCredentials,
|
||||
ImageCredentials,
|
||||
KubernetesCredentials,
|
||||
LovableCredentials,
|
||||
M365CertificateCredentials,
|
||||
M365ClientSecretCredentials,
|
||||
MongoDBAtlasCredentials,
|
||||
@@ -58,6 +59,7 @@ import { GoogleWorkspaceCredentialsForm } from "./via-credentials/googleworkspac
|
||||
import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
|
||||
import { ImageCredentialsForm } from "./via-credentials/image-credentials-form";
|
||||
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
|
||||
import { LovableCredentialsForm } from "./via-credentials/lovable-credentials-form";
|
||||
import { MongoDBAtlasCredentialsForm } from "./via-credentials/mongodbatlas-credentials-form";
|
||||
import { OpenStackCredentialsForm } from "./via-credentials/openstack-credentials-form";
|
||||
import { OracleCloudCredentialsForm } from "./via-credentials/oraclecloud-credentials-form";
|
||||
@@ -279,6 +281,11 @@ export const BaseCredentialsForm = ({
|
||||
control={form.control as unknown as Control<VercelCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "lovable" && (
|
||||
<LovableCredentialsForm
|
||||
control={form.control as unknown as Control<LovableCredentials>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideActions && (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
|
||||
@@ -121,6 +121,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
|
||||
label: "Team ID",
|
||||
placeholder: "e.g. team_xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
};
|
||||
case "lovable":
|
||||
return {
|
||||
label: "Workspace ID",
|
||||
placeholder: "e.g. my-workspace or ws_xxxxxxxx",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "Provider UID",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { LovableCredentials } from "@/types";
|
||||
|
||||
export const LovableCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<LovableCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
Connect via API Token
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Provide a Lovable Cloud API Token with read access to the workspace
|
||||
and projects you want Prowler to assess.
|
||||
</div>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.LOVABLE_API_TOKEN}
|
||||
type="password"
|
||||
label="Lovable API Token"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter your Lovable Cloud API Token"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
Optional: Supabase access token
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
If your Lovable apps are backed by Supabase, supply a Supabase
|
||||
Management API token to enable deeper checks (RLS posture, Edge
|
||||
Function authentication, storage privacy).
|
||||
</div>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN}
|
||||
type="password"
|
||||
label="Supabase Access Token (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="sbp_..."
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<div className="text-default-400 text-xs">
|
||||
Tokens never leave your browser unencrypted and are stored as secrets in
|
||||
the backend. You can revoke either token from the relevant dashboard at
|
||||
any time.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
LovableProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
@@ -49,6 +50,8 @@ export const getProviderLogo = (provider: ProviderType) => {
|
||||
return <OpenStackProviderBadge width={35} height={35} />;
|
||||
case "vercel":
|
||||
return <VercelProviderBadge width={35} height={35} />;
|
||||
case "lovable":
|
||||
return <LovableProviderBadge width={35} height={35} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -86,6 +89,8 @@ export const getProviderName = (provider: ProviderType): string => {
|
||||
return "OpenStack";
|
||||
case "vercel":
|
||||
return "Vercel";
|
||||
case "lovable":
|
||||
return "Lovable";
|
||||
default:
|
||||
return "Unknown Provider";
|
||||
}
|
||||
|
||||
@@ -248,6 +248,12 @@ export const useCredentialsForm = ({
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.VERCEL_API_TOKEN]: "",
|
||||
};
|
||||
case "lovable":
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.LOVABLE_API_TOKEN]: "",
|
||||
[ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN]: "",
|
||||
};
|
||||
default:
|
||||
return baseDefaults;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@ export const getProviderHelpText = (provider: string) => {
|
||||
text: "Need help connecting your Vercel team?",
|
||||
link: "https://goto.prowler.com/provider-vercel",
|
||||
};
|
||||
case "lovable":
|
||||
return {
|
||||
text: "Need help connecting your Lovable workspace?",
|
||||
link: "https://docs.lovable.dev/tips-tricks/security-best-practices",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: "How to setup a provider?",
|
||||
|
||||
@@ -260,6 +260,20 @@ export const buildVercelSecret = (formData: FormData) => {
|
||||
return filterEmptyValues(secret);
|
||||
};
|
||||
|
||||
export const buildLovableSecret = (formData: FormData) => {
|
||||
const secret = {
|
||||
[ProviderCredentialFields.LOVABLE_API_TOKEN]: getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.LOVABLE_API_TOKEN,
|
||||
),
|
||||
[ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN]: getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN,
|
||||
),
|
||||
};
|
||||
return filterEmptyValues(secret);
|
||||
};
|
||||
|
||||
export const buildOpenStackSecret = (formData: FormData) => {
|
||||
const secret = {
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: getFormValue(
|
||||
@@ -513,6 +527,10 @@ export const buildSecretConfig = (
|
||||
secretType: "static",
|
||||
secret: buildVercelSecret(formData),
|
||||
}),
|
||||
lovable: () => ({
|
||||
secretType: "static",
|
||||
secret: buildLovableSecret(formData),
|
||||
}),
|
||||
};
|
||||
|
||||
const builder = secretBuilders[providerType];
|
||||
|
||||
@@ -91,6 +91,10 @@ export const ProviderCredentialFields = {
|
||||
|
||||
// Vercel fields
|
||||
VERCEL_API_TOKEN: "api_token",
|
||||
|
||||
// Lovable fields
|
||||
LOVABLE_API_TOKEN: "api_token",
|
||||
LOVABLE_SUPABASE_ACCESS_TOKEN: "supabase_access_token",
|
||||
} as const;
|
||||
|
||||
// Type for credential field values
|
||||
@@ -150,6 +154,9 @@ export const ErrorPointers = {
|
||||
"/data/attributes/secret/credentials_content",
|
||||
GOOGLEWORKSPACE_DELEGATED_USER: "/data/attributes/secret/delegated_user",
|
||||
VERCEL_API_TOKEN: "/data/attributes/secret/api_token",
|
||||
LOVABLE_API_TOKEN: "/data/attributes/secret/api_token",
|
||||
LOVABLE_SUPABASE_ACCESS_TOKEN:
|
||||
"/data/attributes/secret/supabase_access_token",
|
||||
} as const;
|
||||
|
||||
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];
|
||||
|
||||
@@ -389,6 +389,12 @@ export type VercelCredentials = {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type LovableCredentials = {
|
||||
[ProviderCredentialFields.LOVABLE_API_TOKEN]: string;
|
||||
[ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN]?: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type CredentialsFormSchema =
|
||||
| AWSCredentials
|
||||
| AWSCredentialsRole
|
||||
@@ -406,7 +412,8 @@ export type CredentialsFormSchema =
|
||||
| CloudflareCredentials
|
||||
| OpenStackCredentials
|
||||
| GoogleWorkspaceCredentials
|
||||
| VercelCredentials;
|
||||
| VercelCredentials
|
||||
| LovableCredentials;
|
||||
|
||||
export interface SearchParamsProps {
|
||||
[key: string]: string | string[] | undefined;
|
||||
|
||||
+25
-1
@@ -163,6 +163,17 @@ export const addProviderFormSchema = z
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
|
||||
providerUid: z.string().trim().min(1, "Team ID is required"),
|
||||
}),
|
||||
z.object({
|
||||
providerType: z.literal("lovable"),
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
|
||||
providerUid: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$/,
|
||||
"Workspace ID must be 3-64 alphanumeric characters, dashes, or underscores",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -391,7 +402,20 @@ export const addCredentialsFormSchema = (
|
||||
.trim()
|
||||
.min(1, "API Token is required"),
|
||||
}
|
||||
: {}),
|
||||
: providerType === "lovable"
|
||||
? {
|
||||
[ProviderCredentialFields.LOVABLE_API_TOKEN]:
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(
|
||||
1,
|
||||
"Lovable API Token is required",
|
||||
),
|
||||
[ProviderCredentialFields.LOVABLE_SUPABASE_ACCESS_TOKEN]:
|
||||
z.string().trim().optional(),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.superRefine((data: Record<string, string | undefined>, ctx) => {
|
||||
if (providerType === "m365") {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const PROVIDER_TYPES = [
|
||||
"cloudflare",
|
||||
"openstack",
|
||||
"vercel",
|
||||
"lovable",
|
||||
] as const;
|
||||
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
@@ -34,6 +35,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
openstack: "OpenStack",
|
||||
vercel: "Vercel",
|
||||
lovable: "Lovable",
|
||||
};
|
||||
|
||||
export function getProviderDisplayName(providerId: string): string {
|
||||
|
||||
Reference in New Issue
Block a user