├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml └── twilio_security_scanner ├── Dockerfile ├── __init__.py ├── cli.py ├── config.py ├── requirements.txt ├── scanner.py └── utils.py /.env.example: -------------------------------------------------------------------------------- 1 | TWILIO_ACCOUNT_SID=your_account_sid 2 | TWILIO_API_KEY_SID=your_api_key_sid 3 | TWILIO_API_KEY_SECRET=your_api_key_secret -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python cache 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | *.pyw 7 | *.pyz 8 | 9 | # environment variables 10 | .env 11 | .env* 12 | 13 | # output from tool 14 | output/ 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Relay Hawk, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Read the full blog post [here.](https://www.relayhawk.com/blog/twilio-security) 2 | 3 | # Twilio Security Scanner 4 | 5 | A security scanning tool for Twilio accounts that helps detect misconfigurations and security risks, including: 6 | - Public serverless functions and assets 7 | - Unencrypted HTTP webhooks in phone numbers and messaging services 8 | - API keys older than 90 days 9 | 10 | This tool is useful for DevOps, Security Engineers, and compliance teams looking to audit their Twilio configurations for security best practices. 11 | 12 | ## Prerequisites 13 | 14 | - **Option 1: Docker** (recommended for ease of use) 15 | - **Option 2: Python 3.12** (for running locally) 16 | - Twilio account credentials (Account SID and Auth Token) 17 | 18 | ## Setup 19 | 20 | 1. Clone the repository 21 | ```sh 22 | git clone https://github.com/relayhawk/twilio-security-scanner.git 23 | cd twilio-security-scanner 24 | ``` 25 | 26 | 27 | 2. Setup authentication 28 | 29 | The scanner supports two authentication methods: 30 | 31 | ### Option 1: API Key (Recommended) 32 | Create an API Key in the Twilio Console with these permissions: 33 | 1. Navigate to Console → Account → Account Management → API keys & tokens 34 | 2. Click "Create API Key" 35 | 3. Select "Main" for the API Key type. 36 | * Note: Ideally you would not need this key to be so powerful, but Twilio does not allow us to create a more restrictive key for the permissions we need. We tried the standard key, but we received 401 errors when scanning API keys for old keys. Using a main key is better than using an Auth Token since a service specific api key can easily be revoked and the Auth Token may be used by other systems. 37 | 38 | Then add to your `.env` file: 39 | ```sh 40 | TWILIO_ACCOUNT_SID=your_account_sid 41 | TWILIO_API_KEY_SID=your_api_key_sid 42 | TWILIO_API_KEY_SECRET=your_api_key_secret 43 | ``` 44 | 45 | Note: The API Key needs read permissions for all resources being scanned. If you get a 401 error, verify the API Key has sufficient permissions in the Twilio Console. 46 | 47 | ### Option 2: Auth Token 48 | Use your account's auth token (less secure for production use) and add this to your `.env` file: 49 | ```sh 50 | TWILIO_ACCOUNT_SID=your_account_sid 51 | TWILIO_AUTH_TOKEN=your_auth_token 52 | ``` 53 | 54 | We recommend using API Keys because: 55 | 1. They can be revoked individually 56 | 2. They have more granular permissions 57 | 3. They can be rotated without affecting other systems 58 | 4. They provide better audit trails 59 | 60 | ## Usage 61 | 62 | ### Using Docker Compose 63 | ```sh 64 | docker compose up 65 | ``` 66 | 67 | ### Using Python directly 68 | ```sh 69 | pip install -r requirements.txt 70 | python -m twilio_security_scanner.cli 71 | ``` 72 | 73 | ## Output 74 | 75 | The scanner checks for several security concerns: 76 | 77 | ### Serverless Functions and Assets 78 | - Lists all public functions and assets 79 | - Outputs URLs and paths for each public endpoint 80 | - Saves findings to CSV if specified with `-o` flag 81 | 82 | ### Webhook Security 83 | - Identifies phone numbers using unencrypted HTTP webhooks 84 | - Checks messaging services for unencrypted HTTP URLs 85 | - Reports both primary and fallback URLs using HTTP 86 | 87 | ### API Key Age 88 | - Identifies API keys older than 90 days 89 | - Reports key names for rotation 90 | 91 | ### Trusted Apps 92 | - Lists all trusted connect applications 93 | - Shows count of connected applications 94 | 95 | ## CSV Output 96 | 97 | When using the `-o` flag, the scanner will save public serverless findings to a CSV file with: 98 | - Type (Function/Asset) 99 | - URL 100 | - Path 101 | - SID 102 | - Service Name - The friendly name of the Twilio service containing this function/asset 103 | - Service SID - The unique identifier of the service 104 | 105 | ## Remediation Steps 106 | 107 | ### Public Functions and Assets 108 | If the scanner finds public functions or assets, you can: 109 | 1. Locate the function/asset in the Twilio Console using the provided service name 110 | 2. Navigate to: Console → Functions and Assets → Services → [Service Name] 111 | 3. Review the function/asset visibility settings 112 | 4. Change visibility from "Public" to "Protected" if the endpoint should not be publicly accessible 113 | 5. Consider implementing authentication for endpoints that need controlled access 114 | 115 | Note: Making a function/asset protected will require valid Twilio credentials to access it. 116 | 117 | **Note about Deployment State:** 118 | Functions and assets can exist in two states: 119 | - **Saved but not deployed**: Even if marked as "public", they are not accessible until deployed 120 | - **Deployed**: Will be publicly accessible if marked as "public" 121 | 122 | 123 | ### Unencrypted HTTP Webhooks 124 | For webhooks using HTTP instead of HTTPS: 125 | 1. Update all webhook URLs to use HTTPS 126 | 2. Ensure your webhook endpoints support HTTPS 127 | 3. Update both primary and fallback URLs 128 | 129 | ### Old API Keys 130 | For API keys older than 90 days: 131 | 1. Create new replacement API keys 132 | 2. Update applications to use the new keys 133 | 3. Revoke the old keys after confirming all systems are working 134 | 135 | ## Contributing 136 | 137 | We welcome contributions from the community! If you'd like to contribute: 138 | 139 | * Read the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 140 | * Report bugs or suggest features by opening an [issue](https://github.com/relayhawk/twilio-security-scanner/issues). 141 | 142 | ## License 143 | 144 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 145 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | twilio_scanner: 3 | build: ./twilio_security_scanner 4 | env_file: .env 5 | volumes: 6 | - ./output:/app/output 7 | command: python -m twilio_security_scanner.cli -o /app/output/scan_results.csv 8 | 9 | volumes: 10 | output: 11 | -------------------------------------------------------------------------------- /twilio_security_scanner/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | # Create package directory 6 | RUN mkdir -p /app/twilio_security_scanner 7 | 8 | # Copy requirements first to leverage Docker's layer caching 9 | COPY requirements.txt . 10 | 11 | # Install Python dependencies 12 | RUN pip install --no-cache-dir -r requirements.txt 13 | 14 | # Copy the Python files into the package directory 15 | COPY . /app/twilio_security_scanner/ 16 | 17 | # Set Python path to recognize the module 18 | ENV PYTHONPATH=/app 19 | 20 | CMD ["python", "-m", "twilio_security_scanner.cli"] -------------------------------------------------------------------------------- /twilio_security_scanner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/relayhawk/twilio-security-scanner/9632637e7564b86de573b865054a6011d99e2ca2/twilio_security_scanner/__init__.py -------------------------------------------------------------------------------- /twilio_security_scanner/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | from twilio.rest import Client 5 | from .config import load_config 6 | from .scanner import TwilioSecurityScanner 7 | from .utils import write_items_to_csv 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser(description='Twilio Security Scanner') 14 | parser.add_argument( 15 | '-o', '--output', 16 | type=str, 17 | help='Output CSV filename for public entities' 18 | ) 19 | args = parser.parse_args() 20 | 21 | try: 22 | # Load and validate configuration 23 | credentials = load_config() 24 | 25 | # Initialize client based on authentication method 26 | if len(credentials) == 2 and credentials[0].startswith('SK'): # API Key 27 | api_key_sid, api_key_secret = credentials 28 | account_sid = os.getenv("TWILIO_ACCOUNT_SID") 29 | if not account_sid: 30 | raise ValueError("TWILIO_ACCOUNT_SID is required even when using API Key authentication") 31 | client = Client(api_key_sid, api_key_secret, account_sid) 32 | else: # Auth Token 33 | account_sid, auth_token = credentials 34 | client = Client(account_sid, auth_token) 35 | 36 | scanner = TwilioSecurityScanner(client) 37 | 38 | # Run security checks 39 | public_items = scanner.scan_public_serverless() 40 | insecure_numbers = scanner.scan_phone_numbers() 41 | insecure_messaging = scanner.scan_messaging_services() 42 | old_api_keys = scanner.scan_api_keys() 43 | 44 | # Write results to CSV if requested 45 | if args.output and public_items: 46 | with open(args.output, 'w', newline='') as fp: 47 | write_items_to_csv(public_items, fp) 48 | logger.info(f"Results written to {args.output}") 49 | 50 | # Log summary 51 | logger.info("\nScan Summary:") 52 | logger.info(f"- Public Serverless Items: {len(public_items)}") 53 | logger.info(f"- Insecure Phone Numbers: {len(insecure_numbers)}") 54 | logger.info(f"- Insecure Messaging Services: {len(insecure_messaging)}") 55 | logger.info(f"- Old API Keys: {len(old_api_keys)}") 56 | 57 | except Exception as e: 58 | logger.error(f"Error during scan: {str(e)}") 59 | raise 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /twilio_security_scanner/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dotenv 3 | from typing import Tuple 4 | 5 | 6 | def load_config() -> Tuple[str, str]: 7 | """ 8 | Load and validate Twilio configuration from environment variables. 9 | Supports both AuthToken and API Key authentication. 10 | 11 | Returns: 12 | Tuple[str, str]: A tuple containing either: 13 | - (account_sid, auth_token) for AuthToken auth 14 | - (api_key_sid, api_key_secret) for API Key auth 15 | 16 | Raises: 17 | ValueError: If required environment variables are missing 18 | """ 19 | dotenv.load_dotenv() 20 | 21 | # Check for Account SID first as it's required for all auth methods 22 | account_sid = os.getenv("TWILIO_ACCOUNT_SID") 23 | if not account_sid: 24 | raise ValueError( 25 | "Missing TWILIO_ACCOUNT_SID. This is required for all authentication methods." 26 | ) 27 | 28 | # Try API Key authentication first 29 | api_key_sid = os.getenv("TWILIO_API_KEY_SID") 30 | api_key_secret = os.getenv("TWILIO_API_KEY_SECRET") 31 | 32 | if api_key_sid and api_key_secret: 33 | return api_key_sid, api_key_secret 34 | 35 | # Fall back to Auth Token authentication 36 | auth_token = os.getenv("TWILIO_AUTH_TOKEN") 37 | 38 | if not auth_token: 39 | raise ValueError( 40 | "Missing authentication credentials. Please provide either:\n" 41 | "1. API Key authentication:\n" 42 | " - TWILIO_API_KEY_SID\n" 43 | " - TWILIO_API_KEY_SECRET\n" 44 | "2. Auth Token authentication:\n" 45 | " - TWILIO_AUTH_TOKEN" 46 | ) 47 | 48 | return account_sid, auth_token 49 | -------------------------------------------------------------------------------- /twilio_security_scanner/requirements.txt: -------------------------------------------------------------------------------- 1 | twilio==9.4 2 | python-dotenv==1.0.1 -------------------------------------------------------------------------------- /twilio_security_scanner/scanner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta, timezone 3 | from typing import List 4 | from twilio.rest import Client 5 | from .utils import PublicEntity 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class TwilioSecurityScanner: 11 | def __init__(self, client: Client): 12 | self.client = client 13 | 14 | def scan_phone_numbers(self) -> List[str]: 15 | """Scan for phone numbers with unencrypted HTTP webhooks""" 16 | numbers_with_http_webhooks = [] 17 | logger.debug("Checking phone number webhooks for unencrypted HTTP...") 18 | 19 | for number in self.client.incoming_phone_numbers.stream(): 20 | if (number.voice_url and number.voice_url.startswith('http:')) or \ 21 | (number.sms_url and number.sms_url.startswith('http:')): 22 | logger.warning( 23 | f"Phone number {number.phone_number} has an unencrypted HTTP voice/SMS URL" 24 | ) 25 | numbers_with_http_webhooks.append(number.phone_number) 26 | 27 | if (number.voice_fallback_url and number.voice_fallback_url.startswith('http:')) or \ 28 | (number.sms_fallback_url and number.sms_fallback_url.startswith('http:')): 29 | logger.warning( 30 | f"Phone number {number.phone_number} has an unencrypted HTTP fallback URL" 31 | ) 32 | numbers_with_http_webhooks.append(number.phone_number) 33 | 34 | return numbers_with_http_webhooks 35 | 36 | def scan_messaging_services(self) -> List[str]: 37 | """Scan for messaging services with unencrypted HTTP webhooks""" 38 | services_with_http_webhooks = [] 39 | logger.debug("Checking messaging services for unencrypted HTTP...") 40 | 41 | for service in self.client.messaging.v1.services.stream(): 42 | if service.fallback_url and service.fallback_url.startswith('http:'): 43 | logger.warning( 44 | f"Messaging service {service.friendly_name} has an unencrypted HTTP fallback URL" 45 | ) 46 | services_with_http_webhooks.append(service.friendly_name) 47 | 48 | if service.inbound_request_url and service.inbound_request_url.startswith('http:'): 49 | logger.warning( 50 | f"Messaging service {service.friendly_name} has an unencrypted HTTP inbound request URL" 51 | ) 52 | services_with_http_webhooks.append(service.friendly_name) 53 | 54 | return services_with_http_webhooks 55 | 56 | def scan_api_keys(self) -> List[str]: 57 | """Scan for API keys older than 90 days""" 58 | old_keys = [] 59 | logger.debug("Checking for API keys older than 90 days...") 60 | 61 | for key in self.client.keys.stream(): 62 | now = datetime.utcnow().replace(tzinfo=timezone.utc) 63 | if now - key.date_created > timedelta(days=90): 64 | logger.warning(f"API Key {key.friendly_name} is older than 90 days") 65 | old_keys.append(key.friendly_name) 66 | 67 | return old_keys 68 | 69 | def scan_public_serverless(self) -> List[PublicEntity]: 70 | """Scan for public serverless functions and assets""" 71 | public_items = [] 72 | services = self.client.serverless.v1.services.list() 73 | 74 | if not services: 75 | logger.debug("No serverless services found") 76 | return [] 77 | 78 | for service in services: 79 | logger.debug(f"Checking Service: {service.friendly_name} (SID: {service.sid})") 80 | 81 | domains = self.client.serverless.v1.services(service.sid).environments.list() 82 | service_domain = domains[0].domain_name if domains else None 83 | 84 | if not service_domain: 85 | continue 86 | 87 | # Scan functions 88 | for function in self.client.serverless.v1.services(service.sid).functions.list(): 89 | versions = self.client.serverless.v1.services(service.sid).functions( 90 | function.sid 91 | ).function_versions.list() 92 | 93 | if versions and versions[0].visibility == 'public': 94 | public_items.append( 95 | PublicEntity( 96 | type="Function", 97 | url=f"https://{service_domain}{versions[0].path}", 98 | path=versions[0].path, 99 | SID=function.sid, 100 | service_name=service.friendly_name, 101 | service_sid=service.sid 102 | ) 103 | ) 104 | logger.warning( 105 | f"Public function found in service '{service.friendly_name}': {versions[0].path}" 106 | ) 107 | 108 | # Scan assets 109 | for asset in self.client.serverless.v1.services(service.sid).assets.list(): 110 | versions = self.client.serverless.v1.services(service.sid).assets( 111 | asset.sid 112 | ).asset_versions.list() 113 | 114 | if versions and versions[0].visibility == 'public': 115 | public_items.append( 116 | PublicEntity( 117 | type="Asset", 118 | url=f"https://{service_domain}{versions[0].path}", 119 | path=versions[0].path, 120 | SID=asset.sid, 121 | service_name=service.friendly_name, 122 | service_sid=service.sid 123 | ) 124 | ) 125 | logger.warning( 126 | f"Public asset found in service '{service.friendly_name}': {versions[0].path}" 127 | ) 128 | 129 | return public_items 130 | -------------------------------------------------------------------------------- /twilio_security_scanner/utils.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | from typing import List, TextIO 4 | from dataclasses import dataclass, fields 5 | 6 | # Configure logging 7 | logging.basicConfig( 8 | level=logging.INFO, 9 | format='%(asctime)s - %(levelname)s - %(message)s' 10 | ) 11 | 12 | # Suppress noisy loggers 13 | logging.getLogger('twilio.http_client').setLevel(logging.WARNING) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Colors: 19 | """ANSI color codes for terminal output""" 20 | GREEN = '\033[92m' 21 | YELLOW = '\033[93m' 22 | RED = '\033[91m' 23 | ENDC = '\033[0m' 24 | 25 | 26 | @dataclass 27 | class PublicEntity: 28 | type: str # Either 'Function' or 'Asset' 29 | url: str # The URL at which the public entity is accessible 30 | path: str # Path to the function/asset 31 | SID: str # Unique identifier for the Function or Asset 32 | service_name: str # Name of the service containing this entity 33 | service_sid: str # SID of the service containing this entity 34 | 35 | 36 | def write_items_to_csv(items: List[PublicEntity], file_pointer: TextIO) -> None: 37 | """ 38 | Writes public entities to a CSV file. 39 | 40 | Args: 41 | items: List of public entities to write 42 | file_pointer: File-like object to write to 43 | 44 | Raises: 45 | ValueError: If items list is empty 46 | """ 47 | if not items: 48 | raise ValueError("PublicEntities list is empty. Nothing to write.") 49 | 50 | headers = [field.name for field in fields(PublicEntity)] 51 | writer = csv.writer(file_pointer) 52 | writer.writerow(headers) 53 | 54 | for item in items: 55 | writer.writerow([getattr(item, header) for header in headers]) 56 | --------------------------------------------------------------------------------