├── requirements.txt ├── .gitignore ├── README.md └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.1.31 2 | charset-normalizer==3.4.1 3 | colorama==0.4.6 4 | idna==3.10 5 | requests==2.32.3 6 | ruff==0.9.7 7 | urllib3==2.3.0 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitleaksVerifier CLI 2 | 3 | This project provides a command-line interface (CLI) tool to verify secrets found by gitleaks. It supports various secret types and provides options for verbosity, rule filtering, and output customization. 4 | 5 | ## Features 6 | 7 | - Command-line argument parsing 8 | - Logging configuration with colored output 9 | - Error handling and proper exit codes 10 | - Type hints for better code clarity 11 | - Option to filter by specific rule ID 12 | - JSON output with verification results 13 | - Option to print only valid secrets 14 | 15 | ## Installation 16 | 17 | 1. Clone the repository: 18 | ```bash 19 | git clone https://github.com/aydinnyunus/GitleaksVerifier.git 20 | cd GitleaksVerifier 21 | ``` 22 | 23 | 2. Install the required dependencies: 24 | ```bash 25 | pip install -r requirements.txt 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Gitleaks Example 31 | 32 | ```bash 33 | gitleaks git -f json -r secrets.json 34 | ``` 35 | 36 | Now you can use `secrets.json` file to verify secrets. 37 | 38 | ### Basic Usage 39 | 40 | ```bash 41 | python main.py secrets.json 42 | ``` 43 | 44 | ### Verbose Output 45 | 46 | ```bash 47 | python main.py -v secrets.json 48 | ``` 49 | 50 | ### Filter by Rule 51 | 52 | ```bash 53 | python main.py -r github-token secrets.json 54 | ``` 55 | 56 | ### Specify Output File 57 | 58 | ```bash 59 | python main.py -o results.json secrets.json 60 | ``` 61 | 62 | ### Print Only Valid Secrets 63 | 64 | ```bash 65 | python main.py --only-valid secrets.json 66 | ``` 67 | 68 | ### Show Help 69 | 70 | ```bash 71 | python main.py --help 72 | ``` 73 | 74 | ## Example Output 75 | 76 | The output JSON file will have the following structure: 77 | 78 | ```json 79 | [ 80 | { 81 | "secret": "example_secret", 82 | "rule_id": "github-token", 83 | "valid": true 84 | }, 85 | { 86 | "secret": "invalid_secret", 87 | "rule_id": "slack-token", 88 | "valid": false, 89 | "error": "HTTP 401: Unauthorized" 90 | } 91 | ] 92 | ``` 93 | 94 | ## Supported Secrets 95 | The tool currently verifies the following secrets: 96 | 97 | - Generic API Key 98 | - Cloudflare API Key 99 | - PyPI Upload Token 100 | - Shopify Access Token 101 | - OpenAI API Key 102 | - NPM Access Token 103 | - Datadog Access Token 104 | - Dropbox API Token 105 | - Zendesk Secret Key 106 | - Algolia API Key 107 | - Slack Webhook 108 | - Slack Token 109 | - SauceLabs API Key 110 | - Facebook App Secret 111 | - Grafana Cloud API Token 112 | - Facebook Access Token 113 | - Firebase Token 114 | - GitHub Token (Personal Access Token) 115 | - GitLab Personal Access Token 116 | - GitHub Client Secret 117 | - GitHub SSH Key 118 | - Twilio API Key 119 | - Twitter API Key 120 | - Twitter Bearer Token 121 | - HubSpot API Key 122 | - Infura API Key 123 | - Mailgun Private API Token 124 | - Mapbox API Token 125 | - New Relic User API Key 126 | - DeviantArt Secret Key 127 | - Heroku API Key 128 | - DeviantArt Token 129 | - Pendo API Key 130 | - SendGrid Token 131 | - Square API Token 132 | - Contentful API Token 133 | - Microsoft Tenant ID 134 | - BrowserStack API Key 135 | - Azure Insights Key 136 | - Cypress Record Key 137 | 138 | ## Logging 139 | 140 | The CLI uses the `colorama` library to provide colored output for different log levels: 141 | 142 | - **INFO**: Green 143 | - **WARNING**: Yellow 144 | - **ERROR**: Red 145 | - **DEBUG**: Blue 146 | 147 | It leverages verification methods from [streaak/keyhacks](https://github.com/streaak/keyhacks) for accurate validation. 148 | Thank you for [ozguralp](https://github.com/ozguralp/gmapsapiscanner) for Google Map API Key verification. 149 | 150 | ## Contact 151 | 152 | [](https://linkedin.com/in/yunus-ayd%C4%B1n-b9b01a18a/) [](https://github.com/aydinnyunus/GitleaksVerifier) [](https://instagram.com/aydinyunus_/) [](https://twitter.com/aydinnyunuss) 153 | 154 | 155 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import base64 3 | import json 4 | import logging 5 | import re 6 | import subprocess 7 | import sys 8 | from subprocess import CompletedProcess 9 | from typing import List, Dict, Any, Union, Optional 10 | 11 | import requests 12 | from colorama import init, Fore, Style 13 | 14 | 15 | def setup_logger(verbose: bool) -> logging.Logger: 16 | init() 17 | 18 | logger = logging.getLogger("secret_verifier") 19 | log_level = logging.DEBUG if verbose else logging.INFO 20 | logger.setLevel(log_level) 21 | 22 | class ColoredFormatter(logging.Formatter): 23 | 24 | FORMATS = { 25 | logging.DEBUG: Fore.BLUE + "%(message)s" + Style.RESET_ALL, 26 | logging.INFO: Fore.GREEN + "%(levelname)s: %(message)s" + Style.RESET_ALL, 27 | logging.WARNING: Fore.YELLOW 28 | + "%(levelname)s: %(message)s" 29 | + Style.RESET_ALL, 30 | logging.ERROR: Fore.RED + "%(levelname)s: %(message)s" + Style.RESET_ALL, 31 | logging.CRITICAL: Fore.RED 32 | + Style.BRIGHT 33 | + "%(levelname)s: %(message)s" 34 | + Style.RESET_ALL, 35 | } 36 | 37 | def format(self, record): 38 | log_fmt = self.FORMATS.get(record.levelno) 39 | formatter = logging.Formatter(log_fmt) 40 | return formatter.format(record) 41 | 42 | handler = logging.StreamHandler() 43 | handler.setFormatter(ColoredFormatter()) 44 | logger.addHandler(handler) 45 | 46 | return logger 47 | 48 | 49 | def parse_gitleaks_json(json_file: str) -> List[Dict[str, Any]]: 50 | try: 51 | with open(json_file) as f: 52 | return json.load(f) 53 | except FileNotFoundError: 54 | raise FileNotFoundError(f"Input file '{json_file}' not found") 55 | except json.JSONDecodeError: 56 | raise ValueError(f"Invalid JSON format in file '{json_file}'") 57 | 58 | 59 | def save_results(results: List[Dict[str, Any]], output_file: str) -> None: 60 | with open(output_file, "w") as f: 61 | json.dump(results, f, indent=2) 62 | 63 | 64 | def verify_secrets( 65 | data: List[Dict[str, Any]], logger: logging.Logger, rule_filter: str = None 66 | ) -> list[Union[dict[str, Union[Optional[bool], Any]], CompletedProcess[str]]]: 67 | 68 | results = [] 69 | 70 | for item in data: 71 | rule_id = item.get("RuleID") 72 | secret = item.get("Secret") 73 | match = item.get("Match") 74 | 75 | result = {"secret": secret, "rule_id": rule_id, "valid": False, "match": match} 76 | 77 | if rule_filter and rule_filter.lower() != rule_id.lower(): 78 | continue 79 | 80 | if not secret: 81 | logger.warning(f"No secret found for rule {rule_id}") 82 | continue 83 | 84 | logger.debug(f"Verifying secret for rule: {rule_id}") 85 | 86 | try: 87 | if rule_id == "generic-api-key": 88 | if secret.endswith("=="): 89 | try: 90 | decoded = base64.b64decode(secret).decode("utf-8") 91 | logger.info(f"Decoded generic API key: {decoded}") 92 | result["valid"] = True 93 | except: 94 | logger.warn(f"Failed to decode API key: {secret}") 95 | elif rule_id == "cloudflare-api-key": 96 | response = requests.get( 97 | "https://api.cloudflare.com/client/v4/user/tokens/verify", 98 | headers={"Authorization": f"Bearer {secret}"}, 99 | ) 100 | if response.status_code == 200: 101 | logger.info("Valid Cloudflare API key") 102 | result["valid"] = True 103 | elif rule_id == "pypi-upload-token": 104 | response = requests.get( 105 | "https://upload.pypi.org/legacy/", 106 | headers={"Authorization": f"Basic {secret}"}, 107 | ) 108 | if response.status_code == 200: 109 | logger.info("Valid PyPI upload token") 110 | result["valid"] = True 111 | 112 | elif rule_id == "shopify-access-token": 113 | response = requests.get( 114 | "https://shopify.com/admin/api/2021-07/products.json", 115 | headers={"X-Shopify-Access-Token": secret}, 116 | ) 117 | if response.status_code == 200: 118 | logger.info("Valid Shopify access token") 119 | result["valid"] = True 120 | 121 | elif rule_id == "openai-api-key": 122 | response = requests.get( 123 | "https://api.openai.com/v1/engines", 124 | headers={"Authorization": f"Bearer {secret}"}, 125 | ) 126 | if response.status_code == 200: 127 | logger.info("Valid OpenAI API key") 128 | result["valid"] = True 129 | elif rule_id == "npm-access-token": 130 | response = requests.get( 131 | "https://registry.npmjs.org/-/npm/v1/user", 132 | headers={"Authorization": f"Bearer {secret}"}, 133 | ) 134 | if response.status_code == 200: 135 | logger.info("Valid NPM access token") 136 | result["valid"] = True 137 | elif rule_id == "datadog-access-token": 138 | response = requests.get( 139 | "https://api.datadoghq.com/api/v1/validate", 140 | headers={"DD-API-KEY": secret}, 141 | ) 142 | if response.status_code == 200: 143 | logger.info("Valid Datadog access token") 144 | result["valid"] = True 145 | elif rule_id == "dropbox-api-token": 146 | response = requests.post( 147 | "https://api.dropboxapi.com/2/users/get_current_account", 148 | headers={"Authorization ": f"Bearer {secret}"}, 149 | ) 150 | if response.status_code == 200: 151 | logger.info("Valid Dropbox API token") 152 | result["valid"] = True 153 | elif rule_id == "zendesk-secret-key": 154 | print( 155 | f"curl https://.zendesk.com/api/v2/tickets.json -H 'Authorization: Bearer {secret}'" 156 | ) 157 | result["valid"] = False 158 | elif rule_id == "algolia-api-key": 159 | print( 160 | f"curl --request GET \ 161 | --url https://-1.algolianet.com/1/indexes/ \ 162 | --header 'content-type: application/json' \ 163 | --header 'x-algolia-api-key: {secret}' \ 164 | --header 'x-algolia-application-id: '" 165 | ) 166 | result["valid"] = False 167 | 168 | # Slack verification 169 | elif rule_id == "slack-webhook" or rule_id == "slack-webhook-url": 170 | response = requests.post(match, json={"text": ""}) 171 | if "missing_text_or_fallback_or_attachments" in response.text or "no_text" in response.text: 172 | logger.info("Valid Slack webhook") 173 | result["valid"] = True 174 | 175 | elif rule_id == "slack-token": 176 | if secret.startswith(("xoxp-", "xoxb-")): 177 | response = requests.post( 178 | "https://slack.com/api/auth.test", 179 | headers={"Authorization": f"Bearer {secret}"}, 180 | ) 181 | if response.status_code == 200: 182 | logger.info("Valid Slack token") 183 | result["valid"] = True 184 | 185 | # SauceLabs verification 186 | elif rule_id == "saucelabs": 187 | username = secret.split(":")[0] 188 | access_key = secret.split(":")[1] 189 | response = requests.get( 190 | f"https://saucelabs.com/rest/v1/users/{username}", 191 | auth=(username, access_key), 192 | ) 193 | if response.status_code == 200: 194 | logger.info("Valid SauceLabs credentials") 195 | result["valid"] = True 196 | 197 | # Facebook verification 198 | elif rule_id == "facebook-app-secret": 199 | response = requests.get( 200 | f"https://graph.facebook.com/oauth/access_token?client_id=ID_HERE&client_secret={secret}" 201 | ) 202 | if response.status_code == 200: 203 | logger.info("Valid Facebook app secret") 204 | result["valid"] = True 205 | elif rule_id == "grafana-cloud-api-token": 206 | print( 207 | f'curl -s -H "Authorization: Bearer {secret}" http://your-grafana-server-url.com/api/user' 208 | ) 209 | result["valid"] = True 210 | 211 | elif rule_id == "facebook-access-token": 212 | response = requests.get( 213 | f"https://developers.facebook.com/tools/debug/accesstoken/?access_token={secret}" 214 | ) 215 | if response.status_code == 200: 216 | logger.info("Valid Facebook access token") 217 | result["valid"] = True 218 | elif rule_id == "gcp-api-key": 219 | vulnerable_apis = [] 220 | url = "https://maps.googleapis.com/maps/api/staticmap?center=45%2C10&zoom=7&size=400x400&key=" + secret 221 | response = requests.get(url, verify=False) 222 | if response.status_code == 200: 223 | print( 224 | "API key is \033[1;31;40mvulnerable\033[0m for Staticmap API! Here is the PoC link which can be used directly via browser:") 225 | print(url) 226 | vulnerable_apis.append("Staticmap || $2 per 1000 requests") 227 | elif b"PNG" in response.content: 228 | print("API key is not vulnerable for Staticmap API.") 229 | print("Reason: Manually check the " + url + " to view the reason.") 230 | else: 231 | print("API key is not vulnerable for Staticmap API.") 232 | print("Reason: " + str(response.content)) 233 | 234 | url = "https://maps.googleapis.com/maps/api/streetview?size=400x400&location=40.720032,-73.988354&fov=90&heading=235&pitch=10&key=" + secret 235 | response = requests.get(url, verify=False) 236 | if response.status_code == 200: 237 | print( 238 | "API key is \033[1;31;40mvulnerable\033[0m for Streetview API! Here is the PoC link which can be used directly via browser:") 239 | print(url) 240 | vulnerable_apis.append("Streetview || $7 per 1000 requests") 241 | elif b"PNG" in response.content: 242 | print("API key is not vulnerable for Staticmap API.") 243 | print("Reason: Manually check the " + url + " to view the reason.") 244 | else: 245 | print("API key is not vulnerable for Staticmap API.") 246 | print("Reason: " + str(response.content)) 247 | 248 | url = "https://maps.googleapis.com/maps/api/directions/json?origin=Disneyland&destination=Universal+Studios+Hollywood4&key=" + secret 249 | response = requests.get(url, verify=False) 250 | if response.text.find("error_message") < 0: 251 | print( 252 | "API key is \033[1;31;40mvulnerable\033[0m for Directions API! Here is the PoC link which can be used directly via browser:") 253 | print(url) 254 | vulnerable_apis.append("Directions || $5 per 1000 requests") 255 | vulnerable_apis.append("Directions (Advanced) || $10 per 1000 requests") 256 | else: 257 | print("API key is not vulnerable for Directions API.") 258 | print("Reason: " + response.json()["error_message"]) 259 | 260 | url = "https://maps.googleapis.com/maps/api/geocode/json?latlng=40,30&key=" + secret 261 | response = requests.get(url, verify=False) 262 | if response.text.find("error_message") < 0: 263 | print( 264 | "API key is \033[1;31;40mvulnerable\033[0m for Geocode API! Here is the PoC link which can be used directly via browser:") 265 | print(url) 266 | vulnerable_apis.append("Geocode || $5 per 1000 requests") 267 | else: 268 | print("API key is not vulnerable for Geocode API.") 269 | print("Reason: " + response.json()["error_message"]) 270 | 271 | url = "https://maps.googleapis.com/maps/api/distancematrix/json?units=imperial&origins=40.6655101,-73.89188969999998&destinations=40.6905615%2C-73.9976592%7C40.6905615%2C-73.9976592%7C40.6905615%2C-73.9976592%7C40.6905615%2C-73.9976592%7C40.6905615%2C-73.9976592%7C40.6905615%2C-73.9976592%7C40.659569%2C-73.933783%7C40.729029%2C-73.851524%7C40.6860072%2C-73.6334271%7C40.598566%2C-73.7527626%7C40.659569%2C-73.933783%7C40.729029%2C-73.851524%7C40.6860072%2C-73.6334271%7C40.598566%2C-73.7527626&key=" + secret 272 | response = requests.get(url, verify=False) 273 | if response.text.find("error_message") < 0: 274 | print( 275 | "API key is \033[1;31;40mvulnerable\033[0m for Distance Matrix API! Here is the PoC link which can be used directly via browser:") 276 | print(url) 277 | vulnerable_apis.append("Distance Matrix || $5 per 1000 elements") 278 | vulnerable_apis.append("Distance Matrix (Advanced) || $10 per 1000 elements") 279 | else: 280 | print("API key is not vulnerable for Distance Matrix API.") 281 | print("Reason: " + response.json()["error_message"]) 282 | 283 | url = "https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=Museum%20of%20Contemporary%20Art%20Australia&inputtype=textquery&fields=photos,formatted_address,name,rating,opening_hours,geometry&key=" + secret 284 | response = requests.get(url, verify=False) 285 | if response.text.find("error_message") < 0: 286 | print( 287 | "API key is \033[1;31;40mvulnerable\033[0m for Find Place From Text API! Here is the PoC link which can be used directly via browser:") 288 | print(url) 289 | vulnerable_apis.append("Find Place From Text || $17 per 1000 elements") 290 | else: 291 | print("API key is not vulnerable for Find Place From Text API.") 292 | print("Reason: " + response.json()["error_message"]) 293 | 294 | url = "https://maps.googleapis.com/maps/api/place/autocomplete/json?input=Bingh&types=%28cities%29&key=" + secret 295 | response = requests.get(url, verify=False) 296 | if response.text.find("error_message") < 0: 297 | print( 298 | "API key is \033[1;31;40mvulnerable\033[0m for Autocomplete API! Here is the PoC link which can be used directly via browser:") 299 | print(url) 300 | vulnerable_apis.append("Autocomplete || $2.83 per 1000 requests") 301 | vulnerable_apis.append("Autocomplete Per Session || $17 per 1000 requests") 302 | else: 303 | print("API key is not vulnerable for Autocomplete API.") 304 | print("Reason: " + response.json()["error_message"]) 305 | 306 | url = "https://maps.googleapis.com/maps/api/elevation/json?locations=39.7391536,-104.9847034&key=" + secret 307 | response = requests.get(url, verify=False) 308 | if response.text.find("error_message") < 0: 309 | print( 310 | "API key is \033[1;31;40mvulnerable\033[0m for Elevation API! Here is the PoC link which can be used directly via browser:") 311 | print(url) 312 | vulnerable_apis.append("Elevation || $5 per 1000 requests") 313 | else: 314 | print("API key is not vulnerable for Elevation API.") 315 | print("Reason: " + response.json()["error_message"]) 316 | 317 | url = "https://maps.googleapis.com/maps/api/timezone/json?location=39.6034810,-119.6822510×tamp=1331161200&key=" + secret 318 | response = requests.get(url, verify=False) 319 | if response.text.find("errorMessage") < 0: 320 | print( 321 | "API key is \033[1;31;40mvulnerable\033[0m for Timezone API! Here is the PoC link which can be used directly via browser:") 322 | print(url) 323 | vulnerable_apis.append("Timezone || $5 per 1000 requests") 324 | else: 325 | print("API key is not vulnerable for Timezone API.") 326 | print("Reason: " + response.json()["errorMessage"]) 327 | 328 | url = "https://roads.googleapis.com/v1/nearestRoads?points=60.170880,24.942795|60.170879,24.942796|60.170877,24.942796&key=" + secret 329 | response = requests.get(url, verify=False) 330 | if response.text.find("error") < 0: 331 | print( 332 | "API key is \033[1;31;40mvulnerable\033[0m for Nearest Roads API! Here is the PoC link which can be used directly via browser:") 333 | print(url) 334 | vulnerable_apis.append("Nearest Roads || $10 per 1000 requests") 335 | else: 336 | print("API key is not vulnerable for Nearest Roads API.") 337 | print("Reason: " + response.json()["error"]["message"]) 338 | 339 | url = "https://www.googleapis.com/geolocation/v1/geolocate?key=" + secret 340 | postdata = {'considerIp': 'true'} 341 | response = requests.post(url, data=postdata, verify=False) 342 | if response.text.find("error") < 0: 343 | print( 344 | "API key is \033[1;31;40mvulnerable\033[0m for Geolocation API! Here is the PoC curl command which can be used from terminal:") 345 | print( 346 | "curl -i -s -k -X $'POST' -H $'Host: www.googleapis.com' -H $'Content-Length: 22' --data-binary $'{\"considerIp\": \"true\"}' $'" + url + "'") 347 | vulnerable_apis.append("Geolocation || $5 per 1000 requests") 348 | else: 349 | print("API key is not vulnerable for Geolocation API.") 350 | print("Reason: " + response.json()["error"]["message"]) 351 | 352 | url = "https://roads.googleapis.com/v1/snapToRoads?path=-35.27801,149.12958|-35.28032,149.12907&interpolate=true&key=" + secret 353 | response = requests.get(url, verify=False) 354 | if response.text.find("error") < 0: 355 | print( 356 | "API key is \033[1;31;40mvulnerable\033[0m for Route to Traveled API! Here is the PoC link which can be used directly via browser:") 357 | print(url) 358 | vulnerable_apis.append("Route to Traveled || $10 per 1000 requests") 359 | else: 360 | print("API key is not vulnerable for Route to Traveled API.") 361 | print("Reason: " + response.json()["error"]["message"]) 362 | 363 | url = "https://roads.googleapis.com/v1/speedLimits?path=38.75807927603043,-9.03741754643809&key=" + secret 364 | response = requests.get(url, verify=False) 365 | if response.text.find("error") < 0: 366 | print( 367 | "API key is \033[1;31;40mvulnerable\033[0m for Speed Limit-Roads API! Here is the PoC link which can be used directly via browser:") 368 | print(url) 369 | vulnerable_apis.append("Speed Limit-Roads || $20 per 1000 requests") 370 | else: 371 | print("API key is not vulnerable for Speed Limit-Roads API.") 372 | 373 | print("Reason: " + response.json()["error"]["message"]) 374 | 375 | url = "https://maps.googleapis.com/maps/api/place/details/json?place_id=ChIJN1t_tDeuEmsRUsoyG83frY4&fields=name,rating,formatted_phone_number&key=" + secret 376 | response = requests.get(url, verify=False) 377 | if response.text.find("error_message") < 0: 378 | print( 379 | "API key is \033[1;31;40mvulnerable\033[0m for Place Details API! Here is the PoC link which can be used directly via browser:") 380 | print(url) 381 | vulnerable_apis.append("Place Details || $17 per 1000 requests") 382 | else: 383 | print("API key is not vulnerable for Place Details API.") 384 | print("Reason: " + response.json()["error_message"]) 385 | 386 | url = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=-33.8670522,151.1957362&radius=100&types=food&name=harbour&key=" + secret 387 | response = requests.get(url, verify=False) 388 | if response.text.find("error_message") < 0: 389 | print( 390 | "API key is \033[1;31;40mvulnerable\033[0m for Nearby Search-Places API! Here is the PoC link which can be used directly via browser:") 391 | print(url) 392 | vulnerable_apis.append("Nearby Search-Places || $32 per 1000 requests") 393 | else: 394 | print("API key is not vulnerable for Nearby Search-Places API.") 395 | print("Reason: " + response.json()["error_message"]) 396 | 397 | url = "https://maps.googleapis.com/maps/api/place/textsearch/json?query=restaurants+in+Sydney&key=" + secret 398 | response = requests.get(url, verify=False) 399 | if response.text.find("error_message") < 0: 400 | print( 401 | "API key is \033[1;31;40mvulnerable\033[0m for Text Search-Places API! Here is the PoC link which can be used directly via browser:") 402 | print(url) 403 | vulnerable_apis.append("Text Search-Places || $32 per 1000 requests") 404 | else: 405 | print("API key is not vulnerable for Text Search-Places API.") 406 | print("Reason: " + response.json()["error_message"]) 407 | 408 | url = "https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=CnRtAAAATLZNl354RwP_9UKbQ_5Psy40texXePv4oAlgP4qNEkdIrkyse7rPXYGd9D_Uj1rVsQdWT4oRz4QrYAJNpFX7rzqqMlZw2h2E2y5IKMUZ7ouD_SlcHxYq1yL4KbKUv3qtWgTK0A6QbGh87GB3sscrHRIQiG2RrmU_jF4tENr9wGS_YxoUSSDrYjWmrNfeEHSGSc3FyhNLlBU&key=" + secret 409 | response = requests.get(url, verify=False, allow_redirects=False) 410 | if response.status_code == 302: 411 | print( 412 | "API key is \033[1;31;40mvulnerable\033[0m for Places Photo API! Here is the PoC link which can be used directly via browser:") 413 | print(url) 414 | vulnerable_apis.append("Places Photo || $7 per 1000 requests") 415 | else: 416 | print("API key is not vulnerable for Places Photo API.") 417 | print("Reason: Verbose responses are not enabled for this API, cannot determine the reason.") 418 | 419 | url = "https://fcm.googleapis.com/fcm/send" 420 | postdata = "{'registration_ids':['ABC']}" 421 | response = requests.post(url, data=postdata, verify=False, 422 | headers={'Content-Type': 'application/json', 'Authorization': 'key=' + secret}) 423 | if response.status_code == 200: 424 | print( 425 | "API key is \033[1;31;40mvulnerable\033[0m for FCM API! Here is the PoC curl command which can be used from terminal:") 426 | print( 427 | "curl --header \"Authorization: key=" + secret + "\" --header Content-Type:\"application/json\" https://fcm.googleapis.com/fcm/send -d '{\"registration_ids\":[\"ABC\"]}'") 428 | vulnerable_apis.append("FCM Takeover || https://abss.me/posts/fcm-takeover/") 429 | else: 430 | print("API key is not vulnerable for FCM API.") 431 | for lines in response.iter_lines(): 432 | if (("TITLE") in str(lines)): 433 | print("Reason: " + str(lines).split("TITLE")[1].split("<")[0].replace(">", "")) 434 | 435 | if len(vulnerable_apis) > 0: 436 | result["valid"] = True 437 | 438 | # Firebase verification 439 | elif rule_id == "firebase-token": 440 | response = requests.post( 441 | f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken", 442 | params={"key": secret}, 443 | ) 444 | if response.status_code == 200: 445 | logger.info("Valid Firebase token") 446 | result["valid"] = True 447 | 448 | # GitHub verification 449 | elif rule_id == "github-token" or rule_id == "github-pat": 450 | response = requests.get( 451 | "https://api.github.com/user", 452 | headers={"Authorization": f"token {secret}"}, 453 | ) 454 | if response.status_code == 200: 455 | logger.info("Valid GitHub token") 456 | result["valid"] = True 457 | 458 | elif rule_id == "gitlab-pat": 459 | response = requests.get( 460 | f"https://gitlab.com/api/v4/projects?private_token={secret}" 461 | ) 462 | if response.status_code == 200: 463 | logger.info("Valid Gitlab token") 464 | result["valid"] = True 465 | elif rule_id == "github-client": 466 | client_id = secret.split(":")[0] 467 | client_secret = secret.split(":")[1] 468 | response = requests.get( 469 | f"https://api.github.com/users/whatever", 470 | params={"client_id": client_id, "client_secret": client_secret}, 471 | ) 472 | if response.status_code == 200: 473 | logger.info("Valid GitHub client credentials") 474 | result["valid"] = True 475 | 476 | elif rule_id == "github-ssh": 477 | result = subprocess.run( 478 | ["ssh", "-i", secret, "-T", "git@github.com"], 479 | capture_output=True, 480 | text=True, 481 | ) 482 | if "successfully authenticated" in result.stderr: 483 | logger.info("Valid GitHub SSH key") 484 | result["valid"] = True 485 | 486 | elif rule_id == "twilio": 487 | account_sid = secret.split(":")[0] 488 | auth_token = secret.split(":")[1] 489 | response = requests.get( 490 | "https://api.twilio.com/2010-04-01/Accounts.json", 491 | auth=(account_sid, auth_token), 492 | ) 493 | if response.status_code == 200: 494 | logger.info("Valid Twilio credentials") 495 | result["valid"] = True 496 | 497 | elif rule_id == "twitter-api": 498 | api_key = secret.split(":")[0] 499 | api_secret = secret.split(":")[1] 500 | response = requests.post( 501 | "https://api.twitter.com/oauth2/token", 502 | auth=(api_key, api_secret), 503 | data={"grant_type": "client_credentials"}, 504 | ) 505 | if response.status_code == 200: 506 | logger.info("Valid Twitter API credentials") 507 | result["valid"] = True 508 | 509 | elif rule_id == "twitter-bearer": 510 | response = requests.get( 511 | "https://api.twitter.com/1.1/account_activity/all/subscriptions/count.json", 512 | headers={"Authorization": f"Bearer {secret}"}, 513 | ) 514 | if response.status_code == 200: 515 | logger.info("Valid Twitter bearer token") 516 | result["valid"] = True 517 | 518 | elif rule_id == "hubspot-key": 519 | response = requests.get( 520 | "https://api.hubapi.com/owners/v2/owners", 521 | params={"hsecret": secret}, 522 | ) 523 | if response.status_code == 200: 524 | logger.info("Valid HubSpot API key") 525 | result["valid"] = True 526 | 527 | elif rule_id == "infura-key": 528 | response = requests.post( 529 | f"https://mainnet.infura.io/v3/{secret}", 530 | json={ 531 | "jsonrpc": "2.0", 532 | "method": "eth_accounts", 533 | "params": [], 534 | "id": 1, 535 | }, 536 | ) 537 | if response.status_code == 200: 538 | logger.info("Valid Infura API key") 539 | result["valid"] = True 540 | 541 | elif rule_id == "mailgun-private-api-token": 542 | response = requests.get( 543 | "https://api.mailgun.net/v3/domains", auth=("api", secret) 544 | ) 545 | if response.status_code == 200: 546 | logger.info("Valid Mailgun private API token") 547 | result["valid"] = True 548 | 549 | elif rule_id == "mapbox-api-token": 550 | response = requests.get( 551 | f"https://api.mapbox.com/geocoding/v5/mapbox.places/Los%20Angeles.json?access_token={secret}" 552 | ) 553 | if response.status_code == 200: 554 | logger.info("Valid Mapbox API token") 555 | result["valid"] = True 556 | 557 | elif rule_id == "new-relic-user-api-key": 558 | response = requests.get( 559 | "https://api.newrelic.com/v2/applications.json", 560 | headers={"X-Api-Key": secret}, 561 | ) 562 | if response.status_code == 200: 563 | logger.info("Valid New Relic user API key") 564 | result["valid"] = True 565 | elif rule_id == "deviantart-secret": 566 | response = requests.post( 567 | "https://www.deviantart.com/oauth2/token", 568 | data={ 569 | "grant_type": "client_credentials", 570 | "client_id": "ID_HERE", 571 | "client_secret": secret, 572 | }, 573 | ) 574 | if response.status_code == 200: 575 | logger.info("Valid DeviantArt secret") 576 | result["valid"] = True 577 | elif rule_id == "heroku-api-key": 578 | response = requests.post( 579 | "https://api.heroku.com/apps", 580 | headers={"Authorization": f"Bearer {secret}"}, 581 | ) 582 | if response.status_code == 200: 583 | logger.info("Valid Heroku API key") 584 | result["valid"] = True 585 | elif rule_id == "deviantart-token": 586 | response = requests.post( 587 | "https://www.deviantart.com/api/v1/oauth2/placebo", 588 | data={"access_token": secret}, 589 | ) 590 | if response.status_code == 200: 591 | logger.info("Valid DeviantArt access token") 592 | result["valid"] = True 593 | 594 | elif rule_id == "pendo-key": 595 | response = requests.get( 596 | "https://app.pendo.io/api/v1/feature", 597 | headers={"X-Pendo-Integration-Key": secret}, 598 | ) 599 | if response.status_code == 200: 600 | logger.info("Valid Pendo integration key") 601 | result["valid"] = True 602 | 603 | elif rule_id == "sendgrid-token": 604 | response = requests.get( 605 | "https://api.sendgrid.com/v3/scopes", 606 | headers={"Authorization": f"Bearer {secret}"}, 607 | ) 608 | if response.status_code == 200: 609 | logger.info("Valid SendGrid API token") 610 | result["valid"] = True 611 | 612 | elif rule_id == "square-token": 613 | if re.match(r"EAAA[a-zA-Z0-9]{60}", secret): 614 | response = requests.get( 615 | "https://connect.squareup.com/v2/locations", 616 | headers={"Authorization": f"Bearer {secret}"}, 617 | ) 618 | if response.status_code == 200: 619 | logger.info("Valid Square token") 620 | result["valid"] = True 621 | 622 | elif rule_id == "contentful-token": 623 | response = requests.get( 624 | f"https://cdn.contentful.com/spaces/SPACE_ID/entries", 625 | params={"access_token": secret}, 626 | ) 627 | if response.status_code == 200: 628 | logger.info("Valid Contentful token") 629 | result["valid"] = True 630 | 631 | elif rule_id == "microsoft-tenant": 632 | if re.match(r"[0-9a-z\-]{36}", secret): 633 | logger.info("Valid Microsoft tenant format") 634 | result["valid"] = True 635 | 636 | elif rule_id == "browserstack": 637 | response = requests.get( 638 | "https://api.browserstack.com/automate/plan.json", 639 | auth=(secret.split(":")[0], secret.split(":")[1]), 640 | ) 641 | if response.status_code == 200: 642 | logger.info("Valid BrowserStack access key") 643 | result["valid"] = True 644 | 645 | elif rule_id == "azure-insights": 646 | response = requests.get( 647 | f"https://api.applicationinsights.io/v1/apps/{secret}/metrics/requests/count", 648 | headers={"x-api-key": secret}, 649 | ) 650 | if response.status_code == 200: 651 | logger.info("Valid Azure Insights key") 652 | result["valid"] = True 653 | 654 | elif rule_id == "cypress-record": 655 | response = requests.post( 656 | "https://api.cypress.io/runs", 657 | headers={"x-route-version": "4"}, 658 | json={"projectId": "project_id", "recordKey": secret}, 659 | ) 660 | if response.status_code == 200: 661 | logger.info("Valid Cypress record key") 662 | result["valid"] = True 663 | 664 | else: 665 | logger.warning( 666 | f"No specific verification method for rule ID: {rule_id}, secret: {secret}" 667 | ) 668 | 669 | except Exception as e: 670 | logger.error(f"Error verifying {rule_id}: {str(e)}") 671 | 672 | results.append(result) 673 | return results 674 | 675 | 676 | def main(): 677 | parser = argparse.ArgumentParser( 678 | description="Verify secrets found by gitleaks", 679 | formatter_class=argparse.RawDescriptionHelpFormatter, 680 | ) 681 | 682 | parser.add_argument("json_file", help="Path to the gitleaks JSON output file") 683 | parser.add_argument( 684 | "-v", "--verbose", action="store_true", help="Enable verbose output" 685 | ) 686 | parser.add_argument("-r", "--rule", help="Filter verification by specific rule ID") 687 | parser.add_argument( 688 | "-o", 689 | "--output", 690 | help="Output JSON file for verification results", 691 | default="verification_results.json", 692 | ) 693 | parser.add_argument( 694 | "--only-valid", 695 | action="store_true", 696 | help="Print only valid secrets", 697 | ) 698 | parser.add_argument("--version", action="version", version="%(prog)s 1.0") 699 | 700 | args = parser.parse_args() 701 | 702 | logger = setup_logger(args.verbose) 703 | 704 | try: 705 | data = parse_gitleaks_json(args.json_file) 706 | results = verify_secrets(data, logger, args.rule) 707 | 708 | if args.only_valid: 709 | results = [result for result in results if result.get("valid")] 710 | 711 | save_results(results, args.output) 712 | logger.info(f"Results saved to {args.output}") 713 | except Exception as e: 714 | logger.error(str(e)) 715 | sys.exit(1) 716 | 717 | 718 | if __name__ == "__main__": 719 | main() 720 | --------------------------------------------------------------------------------