├── 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 |
--------------------------------------------------------------------------------