enumendpoint 7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enumendpoint-7.0/LICENSE +21 -0
- enumendpoint-7.0/PKG-INFO +9 -0
- enumendpoint-7.0/README.md +162 -0
- enumendpoint-7.0/enumendpoint.egg-info/PKG-INFO +9 -0
- enumendpoint-7.0/enumendpoint.egg-info/SOURCES.txt +10 -0
- enumendpoint-7.0/enumendpoint.egg-info/dependency_links.txt +1 -0
- enumendpoint-7.0/enumendpoint.egg-info/entry_points.txt +2 -0
- enumendpoint-7.0/enumendpoint.egg-info/requires.txt +4 -0
- enumendpoint-7.0/enumendpoint.egg-info/top_level.txt +1 -0
- enumendpoint-7.0/enumerateendpoint.py +566 -0
- enumendpoint-7.0/pyproject.toml +17 -0
- enumendpoint-7.0/setup.cfg +4 -0
enumendpoint-7.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SphericalFlower52811
|
|
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.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Website Endpoint Scanner and Rate Limit Tester For Websites (Version 7)
|
|
2
|
+
|
|
3
|
+
## How to run
|
|
4
|
+
|
|
5
|
+
Command to run after installing **(For installation, look for the 'Installation' section.)**:
|
|
6
|
+
|
|
7
|
+
Example commands to run:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
enumendpoint (domain, e.g. https://example.com) --ratelimit 100 --testpath /app --show-404s --show-assets
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Passable arguments:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
--ratelimit
|
|
17
|
+
--testpath
|
|
18
|
+
--show-404s
|
|
19
|
+
--disable-extra-files
|
|
20
|
+
--show-assets
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Argument | What the argument does |
|
|
24
|
+
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `--ratelimit` | how many requests to send to server to test. Without this argument, the rate limit test is not performed. |
|
|
26
|
+
| `--testpath` | which endpoint to test for rate limiting. Without this argument, the rate limit test will happen on the root directory ('/'). If an endpoint here returns a 404, it also defaults to the root directory. |
|
|
27
|
+
| `--show-404s` | Show inaccessible endpoints. |
|
|
28
|
+
| `--disable-extra-files` | The script won't scan through extra map files like robots.txt for extra endpoints. |
|
|
29
|
+
| `--show-assets` | Show assets like images that the script finds. |
|
|
30
|
+
|
|
31
|
+
Example use:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
enumendpoint example.com --ratelimit 100 --testpath /api/v1
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
To install enumendpoint, run the command:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python3 -m pip install git+https://github.com/SphericalFlower52811/endpointscanner.git
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
After that, install chromium on playwright (playwright will be installed when you install endpointscanner):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
playwright install chromium
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### You may need to create a virtual environment if there is PEP 668.
|
|
52
|
+
|
|
53
|
+
To create a virtual environment named 'myvenv':
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 -m venv myvenv
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To activate virtual environment on Mac/Linux:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
source myvenv/bin/activate
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
To activate virtual environment on Windows Command Prompt:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
myvenv\Scripts\activate
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
To activate virtual environment on Windows PowerShell:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
myvenv\Scripts\Activate.ps1
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Alternative (Not Recommended)
|
|
78
|
+
|
|
79
|
+
If you do not want to create a virtual environment, you can run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 -m pip install git+https://github.com/SphericalFlower52811/endpointscanner.git --break-system-packages
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
to install it without PEP 668.
|
|
86
|
+
|
|
87
|
+
**Warning**: Using `--break-system-packages` may corrupt your OS-managed python environment. Proceed entirely at your own risk. The author is not liable for any system damage if you run this.
|
|
88
|
+
|
|
89
|
+
### Updating script
|
|
90
|
+
|
|
91
|
+
To update the script, you can run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python3 -m pip install --upgrade git+https://github.com/SphericalFlower52811/endpointscanner.git
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Details
|
|
98
|
+
|
|
99
|
+
I made this a command line tool that you install with the instructions above
|
|
100
|
+
|
|
101
|
+
The command prints out how many endpoints to test, and lists them all out after testing them.
|
|
102
|
+
It can only bypass simple captchas.
|
|
103
|
+
If there is a captcha in the website, it will detect what captcha it is (as long as it is one of the captcha types below)
|
|
104
|
+
|
|
105
|
+
- google recaptcha
|
|
106
|
+
- hcaptcha
|
|
107
|
+
- cloudflare turnstile
|
|
108
|
+
- perimeterX
|
|
109
|
+
- akamai bot manager
|
|
110
|
+
- kasada
|
|
111
|
+
- incapsula
|
|
112
|
+
- amazon captcha
|
|
113
|
+
|
|
114
|
+
If there are signs of a generic captcha in the website, it will say it is a generic captcha.
|
|
115
|
+
|
|
116
|
+
The python code scans for endpoints in websites by looking through all the js files listed in the htmml, and also checks <script></script> tags. It also prints what JS type it uses. It scans for code like get, post etc, href and much more.
|
|
117
|
+
|
|
118
|
+
Types of JS it can detect, but it is a bit buggy and may list the wrong js type.
|
|
119
|
+
Node.js
|
|
120
|
+
React
|
|
121
|
+
Next.js
|
|
122
|
+
Vue
|
|
123
|
+
Angular
|
|
124
|
+
Vite
|
|
125
|
+
Webpack
|
|
126
|
+
|
|
127
|
+
If there is a {id} inside the path, it replaces it with 1 to test the endpoint whether it is a 200 OK, 404/403, or a redirect (30x Header)
|
|
128
|
+
|
|
129
|
+
404(Soft) means the server incorrectly returns 200 response while giving a 404 page instead.
|
|
130
|
+
|
|
131
|
+
It also tries very sensitive endpoints like .env, .git/config, and a lot more.
|
|
132
|
+
|
|
133
|
+
ai assisted code btw
|
|
134
|
+
|
|
135
|
+
The code prints the website uptime, how many seconds it takes to load and whether it is fast or not.
|
|
136
|
+
|
|
137
|
+
The code can also test for rate limiting in the website by performing an async function to send 100 GET requests to an endpoint the user wants to test.
|
|
138
|
+
|
|
139
|
+
## Weaknesses
|
|
140
|
+
|
|
141
|
+
- If there are shells in the page, it may mistake some sensitive endpoints as real. If you see sensitive endpoints in the scan, they may not actually be exposed on the website if the website has a shell. (E.g. .gitignore, .env)
|
|
142
|
+
- If there is a login page, the script will either show that all of the pages require login, or label all of them as 403.
|
|
143
|
+
|
|
144
|
+
## What was added
|
|
145
|
+
|
|
146
|
+
Version 7 added:
|
|
147
|
+
|
|
148
|
+
- Scanning extra map files (e.g. robots.txt, sitemap.xml) for more endpoints
|
|
149
|
+
- Being able to show assets
|
|
150
|
+
- Hiding inaccessible pages by default
|
|
151
|
+
|
|
152
|
+
## Plans for next version
|
|
153
|
+
|
|
154
|
+
Version 8 is planned to have:
|
|
155
|
+
|
|
156
|
+
- Detecting what captcha was used if it is blocked
|
|
157
|
+
- Proper detection of timeouts
|
|
158
|
+
- Optimisation (maybe, if not in v9)
|
|
159
|
+
|
|
160
|
+
# Legal Disclaimer
|
|
161
|
+
|
|
162
|
+
Note that this tool is strictly meant for **authorised** testing and security research. Running this script on websites where you are not permitted to do so can result in legal action. The author of this script assumes no responsibility for any misuse or legal consequences from running this script. Ensure you have received permission from the owner of the target owner before performing tests or scans on their website.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
enumerateendpoint.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
enumendpoint.egg-info/PKG-INFO
|
|
6
|
+
enumendpoint.egg-info/SOURCES.txt
|
|
7
|
+
enumendpoint.egg-info/dependency_links.txt
|
|
8
|
+
enumendpoint.egg-info/entry_points.txt
|
|
9
|
+
enumendpoint.egg-info/requires.txt
|
|
10
|
+
enumendpoint.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
enumerateendpoint
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from curl_cffi import requests
|
|
3
|
+
import re
|
|
4
|
+
from bs4 import BeautifulSoup
|
|
5
|
+
from urllib.parse import urljoin, urlparse
|
|
6
|
+
import argparse
|
|
7
|
+
import time
|
|
8
|
+
from playwright.sync_api import sync_playwright #headless browser to solve captcha
|
|
9
|
+
from playwright_stealth import Stealth #Ensure strict firewalls do not block the playwright browser
|
|
10
|
+
|
|
11
|
+
#Will be fixed in version 8
|
|
12
|
+
'''
|
|
13
|
+
def isthere_captcha(response):
|
|
14
|
+
#me when captcha
|
|
15
|
+
html_content = response.text
|
|
16
|
+
html_lower = html_content.lower()
|
|
17
|
+
|
|
18
|
+
# cloudflare turnstile
|
|
19
|
+
if "challenge-platform" in html_content or "cf-challenge" in html_content:
|
|
20
|
+
return True, "Cloudflare Turnstile (Managed Challenge)"
|
|
21
|
+
if "window._cf_chl_opt" in html_content or "cf-ray:" in html_lower:
|
|
22
|
+
return True, "Cloudflare WAF Block Page"
|
|
23
|
+
if "cf-mitigated" in response.headers.get("Server", "").lower():
|
|
24
|
+
return True, "Cloudflare Edge Mitigation"
|
|
25
|
+
|
|
26
|
+
# perimeterx
|
|
27
|
+
if "window._pxappid" in html_lower or "px-captcha" in html_lower:
|
|
28
|
+
return True, "Human Security (PerimeterX) CAPTCHA"
|
|
29
|
+
if "captcha.px-cdn.net" in html_content or "client.perimeterx.net" in html_content:
|
|
30
|
+
return True, "Human Security (PerimeterX) Shield Active"
|
|
31
|
+
|
|
32
|
+
#recaptcha
|
|
33
|
+
if "google.com" in html_lower or "g-recaptcha" in html_lower:
|
|
34
|
+
return True, "Google reCAPTCHA Challenge"
|
|
35
|
+
if "recaptcha.js" in html_lower or "__recaptcha_api" in html_lower:
|
|
36
|
+
return True, "Google reCAPTCHA Script Loaded"
|
|
37
|
+
|
|
38
|
+
#h captcha
|
|
39
|
+
if "hcaptcha.com" in html_content or "h-captcha" in html_lower:
|
|
40
|
+
return True, "hCaptcha Verification Screen"
|
|
41
|
+
|
|
42
|
+
#akamai
|
|
43
|
+
if "akam_bm" in response.cookies or "bm_sz" in response.cookies:
|
|
44
|
+
return True, "Akamai Bot Manager Cookie Block"
|
|
45
|
+
if "_sec_challenge" in html_lower or "akamai-extension" in html_lower:
|
|
46
|
+
return True, "Akamai WAF Challenge Injection"
|
|
47
|
+
|
|
48
|
+
# aws/amazon captcha
|
|
49
|
+
if "aws-waf-token" in html_lower or "awswaf" in html_lower:
|
|
50
|
+
return True, "AWS WAF Token Challenge"
|
|
51
|
+
if "amazon captcha" in html_lower or "amzn-captcha" in html_lower:
|
|
52
|
+
return True, "Amazon Custom CAPTCHA Screen"
|
|
53
|
+
|
|
54
|
+
# incapsula or soemthing
|
|
55
|
+
if "incapsula" in html_lower or "_incap_" in html_lower:
|
|
56
|
+
return True, "Imperva Incapsula Bot Shield"
|
|
57
|
+
if "visid_incap" in response.cookies:
|
|
58
|
+
return True, "Imperva Session Interception"
|
|
59
|
+
|
|
60
|
+
# kasada
|
|
61
|
+
if "kpsdk" in html_lower or "ips.js" in html_lower:
|
|
62
|
+
return True, "Kasada Anti-Bot Handshake"
|
|
63
|
+
|
|
64
|
+
# generic captchas
|
|
65
|
+
if response.status_code in [403, 429]:
|
|
66
|
+
generic_signals = ["captcha", "robot", "automated access", "verify you are human", "checking your browser"]
|
|
67
|
+
for signal in generic_signals:
|
|
68
|
+
if signal in html_lower:
|
|
69
|
+
return True, f"Generic Firewall Block ({signal.title()})"
|
|
70
|
+
return True, f"Unidentified Security Drop (HTTP {response.status_code})"
|
|
71
|
+
|
|
72
|
+
return False, ""
|
|
73
|
+
'''
|
|
74
|
+
HEADER = {
|
|
75
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
76
|
+
"Accept-Language": "en-US, en;q=0.9"
|
|
77
|
+
} #browser header
|
|
78
|
+
|
|
79
|
+
def gethtmlafterload(url):
|
|
80
|
+
with sync_playwright() as p:
|
|
81
|
+
browser = p.chromium.launch(
|
|
82
|
+
headless=True,
|
|
83
|
+
args=[
|
|
84
|
+
"--disable-blink-features=AutomationControlled",
|
|
85
|
+
"--no-sandbox",
|
|
86
|
+
"--disable-infobars"
|
|
87
|
+
])
|
|
88
|
+
context = browser.new_context(
|
|
89
|
+
user_agent=HEADER['User-Agent'],
|
|
90
|
+
viewport={'width': 1920, 'height': 1080},
|
|
91
|
+
has_touch=True
|
|
92
|
+
)
|
|
93
|
+
page = context.new_page()
|
|
94
|
+
Stealth().apply_stealth_sync(page) #stealth
|
|
95
|
+
|
|
96
|
+
#go to page and get code, using stealth to bypass captchas
|
|
97
|
+
try:
|
|
98
|
+
page.goto(url, wait_until="domcontentloaded", timeout=60000) #wait for page content to download
|
|
99
|
+
page.mouse.move(100, 100)
|
|
100
|
+
page.mouse.move(200, 300)
|
|
101
|
+
page.evaluate("window.scrollTo(0, 500)")
|
|
102
|
+
|
|
103
|
+
page.wait_for_timeout(5000) #wait for page to load downloaded content, and cookie
|
|
104
|
+
mainhtml = page.content()
|
|
105
|
+
cookies = {c['name']: c['value'] for c in context.cookies()}
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print("Unexpected Error:", e)
|
|
109
|
+
mainhtml, cookies = "", {}
|
|
110
|
+
|
|
111
|
+
browser.close()
|
|
112
|
+
return mainhtml, cookies
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def identify_javascript_type(html, headers=None):
|
|
116
|
+
stack = []
|
|
117
|
+
#print(f"\n[DEBUG] HTML Snippet: {html[:1000]}\n")
|
|
118
|
+
# Next.js
|
|
119
|
+
if any(term in html for term in ['data-next-head', 'script id="__NEXT_DATA__"', 'next-head-count', '_next/', '_next/data']):
|
|
120
|
+
stack.append("Next.js")
|
|
121
|
+
# React
|
|
122
|
+
if 'data-reactroot' in html or 'id="root"' in html or 'react-dom' in html.lower():
|
|
123
|
+
stack.append("React")
|
|
124
|
+
# Vue / Angular
|
|
125
|
+
if 'id="app"' in html or 'v-bind' in html: stack.append("Vue.js")
|
|
126
|
+
if 'id="__nuxt"' in html or 'window.__NUXT__' in html: stack.append("Nuxt.js (Vue)")
|
|
127
|
+
if '<app-root' in html or 'ng-version' in html or '_nghost-' in html: stack.append("Angular")
|
|
128
|
+
# Node.js
|
|
129
|
+
if headers:
|
|
130
|
+
powered_by = headers.get('X-Powered-By', '').lower()
|
|
131
|
+
if 'express' in powered_by or 'node' in powered_by:
|
|
132
|
+
stack.append(f"Node.js ({powered_by.capitalize()})")
|
|
133
|
+
# Build Tools
|
|
134
|
+
if any(term in html.lower() for term in ['@vite/client', 'vite-plugin', 'src="/@vite']):
|
|
135
|
+
stack.append("Vite")
|
|
136
|
+
if 'webpack' in html.lower(): stack.append("Webpack")
|
|
137
|
+
|
|
138
|
+
return " + ".join(stack) if stack else "Unknown JS Stack"
|
|
139
|
+
|
|
140
|
+
async def async_rate_test(url, num_reqs=100):
|
|
141
|
+
print(f"\nStarting Rate Limit Test: {num_reqs} requests to {url}")
|
|
142
|
+
|
|
143
|
+
async with requests.AsyncSession(impersonate="chrome120") as session:
|
|
144
|
+
tasks = [session.get(url, headers=HEADER, timeout=10) for _ in range(num_reqs)]
|
|
145
|
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
|
146
|
+
|
|
147
|
+
status_counts = {}
|
|
148
|
+
first_limit_at = None
|
|
149
|
+
|
|
150
|
+
for i, res in enumerate(responses):
|
|
151
|
+
request_number = i + 1
|
|
152
|
+
if isinstance(res, Exception):
|
|
153
|
+
status_counts['Error'] = status_counts.get('Error', 0) + 1
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
code = res.status_code
|
|
157
|
+
status_counts[code] = status_counts.get(code, 0) + 1
|
|
158
|
+
|
|
159
|
+
if code != 200 and first_limit_at is None:
|
|
160
|
+
first_limit_at = (request_number, code)
|
|
161
|
+
|
|
162
|
+
print("\n--- Rate Limit Results ---")
|
|
163
|
+
for code, count in status_counts.items():
|
|
164
|
+
if code == 'Error': continue
|
|
165
|
+
label = "OK" if code == 200 else "LIMITED" if code == 429 else "WAF/FORBIDDEN" if code == 403 else "CRASHED" if code == 500 else "Other"
|
|
166
|
+
print(f" Status {code} ({label}): {count}")
|
|
167
|
+
|
|
168
|
+
if status_counts.get(200, 0) == num_reqs:
|
|
169
|
+
print(f"\nWebsite is potentially vulnerable to DoS or brute-forcing (No rate limit detected after {num_reqs} requests).")
|
|
170
|
+
elif first_limit_at:
|
|
171
|
+
req_num, code = first_limit_at
|
|
172
|
+
if code == 403:
|
|
173
|
+
print(f"\nA WAF (Firewall) likely intercepted the requests (403 Forbidden after {req_num} requests).")
|
|
174
|
+
elif code == 429:
|
|
175
|
+
print(f"\nServer-side rate limiting is active (429 Too Many Requests detected after {req_num} requests).")
|
|
176
|
+
else:
|
|
177
|
+
print(f"\nServer began responding with {code} after {req_num} requests.")
|
|
178
|
+
|
|
179
|
+
def main():
|
|
180
|
+
parser = argparse.ArgumentParser()
|
|
181
|
+
parser.add_argument("target", nargs='?', help="URL")
|
|
182
|
+
parser.add_argument("--ratelimit", nargs='?', const=100, type=int, default=None, help="Number of requests")
|
|
183
|
+
parser.add_argument("--testpath", nargs='?', const='/', type=str, help="Endpoint to test")
|
|
184
|
+
parser.add_argument("--show-404s", action="store_true", help="Show endpoints tested that returned a 404")
|
|
185
|
+
parser.add_argument("--disable-extra-files", action="store_true", help="Disable scanning of extra structural mapping files (robots, sitemaps, manifests, etc.)")
|
|
186
|
+
parser.add_argument("--show-assets", action="store_true", help="Include assets like images and fonts in scan results")
|
|
187
|
+
|
|
188
|
+
args = parser.parse_args()
|
|
189
|
+
if args.testpath and args.ratelimit is None:
|
|
190
|
+
args.ratelimit = 100
|
|
191
|
+
|
|
192
|
+
ignored_extensions = ()
|
|
193
|
+
|
|
194
|
+
# error if the user somehow didn't read the instructions (i've idiotproofed the code ENOUGH)
|
|
195
|
+
if args.testpath and args.ratelimit is None:
|
|
196
|
+
parser.error("--testpath requires the --ratelimit flag.\nIf you want to do a rate limit test, use --ratelimit (number of requests) --testpath (path to test).\nIf not, don't use --ratelimit nor --testpath.")
|
|
197
|
+
|
|
198
|
+
show_dead = args.show_404s
|
|
199
|
+
target = args.target if args.target else input("Enter website (e.g. https://example.com): ").strip()
|
|
200
|
+
if not target.startswith(("http://", "https://")):
|
|
201
|
+
target = "https://" + target
|
|
202
|
+
try:
|
|
203
|
+
response = requests.get(target, headers=HEADER, timeout=5, impersonate="chrome120")
|
|
204
|
+
except requests.exceptions.SSLError:
|
|
205
|
+
if target.startswith("https://"):
|
|
206
|
+
print('[!] HTTPS SSL Error. Trying HTTP...') #becuase later sum http noob
|
|
207
|
+
target = target.replace("https://", "http://")
|
|
208
|
+
try:
|
|
209
|
+
requests.get(target, headers=HEADER, timeout=5, impersonate="chrome120")
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f'[!] Target unreachable on HTTP: {e}')
|
|
212
|
+
exit()
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f'Target unreachable: {e}')
|
|
215
|
+
exit()
|
|
216
|
+
try:
|
|
217
|
+
st = time.perf_counter()
|
|
218
|
+
uptimeres = requests.get(target, headers=HEADER, timeout=10, impersonate="chrome120")
|
|
219
|
+
et = time.perf_counter()
|
|
220
|
+
restime = et - st
|
|
221
|
+
print(f"Site responded in {round(restime, 2)} seconds.")
|
|
222
|
+
|
|
223
|
+
if restime < 0.5:
|
|
224
|
+
print("Server is very fast.")
|
|
225
|
+
elif restime < 1:
|
|
226
|
+
print("Server is fast.")
|
|
227
|
+
elif restime < 2.5:
|
|
228
|
+
print("Server is average speed.")
|
|
229
|
+
elif restime < 4:
|
|
230
|
+
print("Server is slow.")
|
|
231
|
+
else:
|
|
232
|
+
print("Server is very slow.")
|
|
233
|
+
except requests.exceptions.Timeout:
|
|
234
|
+
print("Server did not respond after 10 seconds.")
|
|
235
|
+
|
|
236
|
+
SENSITIVE_ENDPOINT = {
|
|
237
|
+
"/.env", "/.env.local", "/.env.production", "/.env.development",
|
|
238
|
+
"/.git/config", "/.git/HEAD", "/robots.txt", "/sitemap.xml",
|
|
239
|
+
"/package.json", "/package-lock.json", "/.npmrc", "/.dockerenv",
|
|
240
|
+
"/.gitignore", "/api/health", "/admin", "/login", "/config"
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
results_fromotherfiles = []
|
|
244
|
+
found_paths = set(SENSITIVE_ENDPOINT)
|
|
245
|
+
discovered_in_js = set()
|
|
246
|
+
|
|
247
|
+
if not args.disable_extra_files:
|
|
248
|
+
print("\nFinding paths from map files. (If they exist)")
|
|
249
|
+
|
|
250
|
+
# bobot.txt
|
|
251
|
+
try:
|
|
252
|
+
r_res = requests.get(urljoin(target, "/robots.txt"), headers=HEADER, impersonate="chrome120", timeout=4)
|
|
253
|
+
if r_res.status_code == 200 and "disallow" in r_res.text.lower():
|
|
254
|
+
rules = re.findall(r'(?:Disallow|Allow):\s*(/[a-zA-Z0-9_\-\./{}:|]*)', r_res.text, re.IGNORECASE)
|
|
255
|
+
for rule in rules:
|
|
256
|
+
clean_rule = rule.strip()
|
|
257
|
+
if clean_rule and clean_rule not in ["/", "/*"] and clean_rule not in found_paths:
|
|
258
|
+
found_paths.add(clean_rule)
|
|
259
|
+
results_fromotherfiles.append(f"{clean_rule} [Source: robots.txt]")
|
|
260
|
+
except: pass
|
|
261
|
+
|
|
262
|
+
# sitemap
|
|
263
|
+
try:
|
|
264
|
+
s_res = requests.get(urljoin(target, "/sitemap.xml"), headers=HEADER, impersonate="chrome120", timeout=4)
|
|
265
|
+
if s_res.status_code == 200 and "<loc" in s_res.text.lower():
|
|
266
|
+
locs = re.findall(r'<loc>https?://[^/]+(/[^<]+)</loc>', s_res.text, re.IGNORECASE)
|
|
267
|
+
for loc in locs:
|
|
268
|
+
clean_path = loc.strip()
|
|
269
|
+
if clean_path and clean_path != "/" and clean_path not in found_paths:
|
|
270
|
+
found_paths.add(clean_path)
|
|
271
|
+
results_fromotherfiles.append(f"{clean_path} [Source: sitemap.xml]")
|
|
272
|
+
except: pass
|
|
273
|
+
|
|
274
|
+
# me when manifesto without the o
|
|
275
|
+
try:
|
|
276
|
+
m_res = requests.get(urljoin(target, "/asset-manifest.json"), headers=HEADER, impersonate="chrome120", timeout=4)
|
|
277
|
+
if m_res.status_code == 200 and "{" in m_res.text:
|
|
278
|
+
paths = re.findall(r'["\'](/[a-zA-Z0-9_\-\./]+)["\']', m_res.text)
|
|
279
|
+
for path in paths:
|
|
280
|
+
if path not in found_paths:
|
|
281
|
+
found_paths.add(path)
|
|
282
|
+
results_fromotherfiles.append(f"{path} [Source: asset-manifest.json]")
|
|
283
|
+
except: pass
|
|
284
|
+
for manifest_path in ["/web-manifest.json", "/manifest.json"]:
|
|
285
|
+
try:
|
|
286
|
+
m_url = urljoin(target, manifest_path)
|
|
287
|
+
m_res = requests.get(m_url, headers=HEADER, impersonate="chrome120", timeout=4)
|
|
288
|
+
if m_res.status_code == 200 and "{" in m_res.text:
|
|
289
|
+
paths = re.findall(r'["\'](/[a-zA-Z0-9_\-\./]+)["\']', m_res.text)
|
|
290
|
+
for path in paths:
|
|
291
|
+
if path not in found_paths:
|
|
292
|
+
found_paths.add(path)
|
|
293
|
+
results_fromotherfiles.append(f"{path} [Source: {manifest_path.lstrip('/')}]")
|
|
294
|
+
except: pass
|
|
295
|
+
# service worker lol
|
|
296
|
+
for sw_path in ["/service-worker.js", "/sw.js"]:
|
|
297
|
+
try:
|
|
298
|
+
sw_res = requests.get(urljoin(target, sw_path), headers=HEADER, impersonate="chrome120", timeout=4)
|
|
299
|
+
if sw_res.status_code == 200:
|
|
300
|
+
paths = re.findall(r'["\'`](/[a-zA-Z0-9_\-\./{}:]+)["\'`]', sw_res.text)
|
|
301
|
+
for path in paths:
|
|
302
|
+
if path not in found_paths and not any(path.endswith(ext) for ext in ['.js', '.css']):
|
|
303
|
+
found_paths.add(path)
|
|
304
|
+
results_fromotherfiles.append(f"{path} [Source: {sw_path}]")
|
|
305
|
+
except: pass
|
|
306
|
+
|
|
307
|
+
#openid
|
|
308
|
+
try:
|
|
309
|
+
oidc_res = requests.get(urljoin(target, "/.well-known/openid-configuration"), headers=HEADER, impersonate="chrome120", timeout=4)
|
|
310
|
+
if oidc_res.status_code == 200 and "{" in oidc_res.text:
|
|
311
|
+
paths = re.findall(r'https?://[^/]+(/[^"\']*)', oidc_res.text)
|
|
312
|
+
for path in paths:
|
|
313
|
+
if path not in found_paths:
|
|
314
|
+
found_paths.add(path)
|
|
315
|
+
results_fromotherfiles.append(f"{path} [Source: openid-configuration]")
|
|
316
|
+
except: pass
|
|
317
|
+
|
|
318
|
+
print("\nStarting headless browser to bypass captchas and detect shells with a fake path.")
|
|
319
|
+
main_html, session_cookies = gethtmlafterload(target)
|
|
320
|
+
|
|
321
|
+
fake_path = "/very-fake-page-123456123456abcdefg"
|
|
322
|
+
fake_url = urljoin(target, fake_path)
|
|
323
|
+
try:
|
|
324
|
+
fake_res = requests.get(fake_url, cookies=session_cookies, headers=HEADER, impersonate="chrome120", timeout=10)
|
|
325
|
+
shell_content = fake_res.text
|
|
326
|
+
except:
|
|
327
|
+
shell_content = ""
|
|
328
|
+
try:
|
|
329
|
+
print(f"Detected JS Type: {identify_javascript_type(main_html)}")
|
|
330
|
+
soup = BeautifulSoup(main_html, 'html.parser')
|
|
331
|
+
|
|
332
|
+
# next js code files
|
|
333
|
+
js_files = [urljoin(target, s.get('src')) for s in soup.find_all('script') if s.get('src')]
|
|
334
|
+
js_files.append(urljoin(target, "/_next/static/development/_buildManifest.js"))
|
|
335
|
+
js_files.append(urljoin(target, "/_next/static/runtime/_buildManifest.js"))
|
|
336
|
+
|
|
337
|
+
for script_tag in soup.find_all('script'):
|
|
338
|
+
src = script_tag.get('src')
|
|
339
|
+
if src and not src.startswith(('http://', 'https://')):
|
|
340
|
+
# Use urlparse to strip away parameters and isolate a clean local path
|
|
341
|
+
local_path = urlparse(src).path
|
|
342
|
+
if local_path:
|
|
343
|
+
# Guarantee exactly ONE starting slash, nothing more, nothing less
|
|
344
|
+
clean_path = '/' + local_path.lstrip('/')
|
|
345
|
+
found_paths.add(clean_path)
|
|
346
|
+
|
|
347
|
+
# Gather stylesheets safely without corrupting string slashes
|
|
348
|
+
for link_tag in soup.find_all('link', rel='stylesheet'):
|
|
349
|
+
href = link_tag.get('href')
|
|
350
|
+
if href and not href.startswith(('http://', 'https://')):
|
|
351
|
+
local_path = urlparse(href).path
|
|
352
|
+
if local_path:
|
|
353
|
+
clean_path = '/' + local_path.lstrip('/')
|
|
354
|
+
found_paths.add(clean_path)
|
|
355
|
+
patterns = [
|
|
356
|
+
r'["\'`](/[a-zA-Z0-9_\-\./{}:]*)["\'`]',
|
|
357
|
+
r'(?:path|href|to|post|get|patch|put|delete|head|options)[\s]*[:=\(\|]+[\s]*["\'`](/?[a-zA-Z0-9_\-\./{}:\$]*[\./][a-zA-Z0-9_\-\./{}:\$]*)["\'`]',
|
|
358
|
+
r'[`](https?://[a-zA-Z0-9_\-\./{}:\$]+)[`]'
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
# check <script> for endpoint too
|
|
362
|
+
inline_scripts = soup.find_all('script')
|
|
363
|
+
for script in inline_scripts:
|
|
364
|
+
if script.string:
|
|
365
|
+
chunks = re.findall(r'["\'](/[a-zA-Z0-9_\-\./]*\.js)["\']', script.string)
|
|
366
|
+
for c in chunks:
|
|
367
|
+
js_files.append(urljoin(target, c))
|
|
368
|
+
|
|
369
|
+
for p in patterns:
|
|
370
|
+
matches = re.findall(p, script.string)
|
|
371
|
+
for m in matches:
|
|
372
|
+
m_clean = re.sub(r'(\$\{.*\}|:[a-zA-Z0-9]+)', '1', m)
|
|
373
|
+
if not m_clean.startswith('/'): m_clean = '/' + m_clean
|
|
374
|
+
|
|
375
|
+
if not m_clean.lower().endswith(ignored_extensions):
|
|
376
|
+
if m_clean in ["/", "//", "///", "/.", "/..", "/..."]:
|
|
377
|
+
continue
|
|
378
|
+
found_paths.add(m_clean)
|
|
379
|
+
discovered_in_js.add(m_clean)
|
|
380
|
+
|
|
381
|
+
# scan all js files
|
|
382
|
+
for js_url in js_files:
|
|
383
|
+
try:
|
|
384
|
+
js_res = requests.get(js_url, headers=HEADER, cookies=session_cookies, timeout=5, impersonate="chrome120")
|
|
385
|
+
if js_res.status_code == 200:
|
|
386
|
+
for p in patterns:
|
|
387
|
+
matches = re.findall(p, js_res.text)
|
|
388
|
+
for m in matches:
|
|
389
|
+
m_clean = re.sub(r'(\$\{.*?\}|:[a-zA-Z0-9]+)', '1', m)
|
|
390
|
+
if not m_clean.startswith('/'): m_clean = '/' + m_clean
|
|
391
|
+
|
|
392
|
+
if not m_clean.lower().endswith(ignored_extensions):
|
|
393
|
+
if m_clean in ["/", "//", "///", "/.", "/..", "/..."]:
|
|
394
|
+
continue
|
|
395
|
+
found_paths.add(m_clean)
|
|
396
|
+
discovered_in_js.add(m_clean)
|
|
397
|
+
except: continue
|
|
398
|
+
|
|
399
|
+
print(f"Total paths to test: {len(found_paths)} ({len(discovered_in_js)} scraped from JS).")
|
|
400
|
+
print("Testing endpoints...")
|
|
401
|
+
results_200, results_dead, results_30x = [], [], []
|
|
402
|
+
results_services, results_ext = [], []
|
|
403
|
+
results_frameworks, results_assets = [], []
|
|
404
|
+
|
|
405
|
+
for path in sorted(found_paths):
|
|
406
|
+
# all external url cuz https: //blahblah
|
|
407
|
+
if "://" in path:
|
|
408
|
+
results_ext.append(path.lstrip('/'))
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
parsed_path = urlparse(path)
|
|
413
|
+
target_domain = urlparse(target).netloc
|
|
414
|
+
|
|
415
|
+
# get the base domain (efg.hijk from abcd.efg.hijk)
|
|
416
|
+
def get_base(domain):
|
|
417
|
+
parts = domain.split('.')
|
|
418
|
+
return ".".join(parts[-2:]) if len(parts) > 1 else domain
|
|
419
|
+
|
|
420
|
+
is_external = parsed_path.netloc and get_base(parsed_path.netloc) != get_base(target_domain)
|
|
421
|
+
|
|
422
|
+
if is_external:
|
|
423
|
+
results_ext.append(f"{path.lstrip('/')} [External Reference]")
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
r = requests.get(
|
|
428
|
+
urljoin(target, path),
|
|
429
|
+
headers=HEADER,
|
|
430
|
+
cookies=session_cookies,
|
|
431
|
+
timeout=5,
|
|
432
|
+
allow_redirects=False,
|
|
433
|
+
impersonate="chrome120"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
content_type = r.headers.get("Content-Type", "").lower()
|
|
437
|
+
is_shell = (r.text == shell_content)
|
|
438
|
+
is_home_redirect = (r.text == main_html)
|
|
439
|
+
|
|
440
|
+
# common service and api i think
|
|
441
|
+
service_markers = ["/api", "/v1", "/v2", "socket.io", "engine.io", "/graphql", "/webhook", "/rpc"]
|
|
442
|
+
is_machine_path = any(marker in path.lower() for marker in service_markers)
|
|
443
|
+
|
|
444
|
+
media_extensions = ('.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif', '.ico', '.woff', '.woff2', '.ttf')
|
|
445
|
+
framework_extensions = ('.js', '.css', '.json', '.txt', '.xml', '.map')
|
|
446
|
+
|
|
447
|
+
is_media_asset = path.lower().endswith(media_extensions)
|
|
448
|
+
is_framework_asset = any(path.lower().endswith(ext) for ext in framework_extensions)
|
|
449
|
+
|
|
450
|
+
if r.status_code == 200:
|
|
451
|
+
# if its a service/api path then like service and stuff ykyk
|
|
452
|
+
if is_machine_path:
|
|
453
|
+
results_services.append(f"{path} [Service/API]")
|
|
454
|
+
elif is_shell or is_home_redirect:
|
|
455
|
+
if path in discovered_in_js:
|
|
456
|
+
results_200.append(f"{path} [Client-Side Route, Requires Login]")
|
|
457
|
+
else:
|
|
458
|
+
results_dead.append(f"404 Not Found (React Shell): {path}")
|
|
459
|
+
else:
|
|
460
|
+
if "text/html" in content_type:
|
|
461
|
+
results_200.append(f"{path} [Access no matter what]")
|
|
462
|
+
elif is_media_asset:
|
|
463
|
+
results_assets.append(f"{path}")
|
|
464
|
+
elif is_framework_asset:
|
|
465
|
+
results_frameworks.append(f"{path}")
|
|
466
|
+
else:
|
|
467
|
+
#standard is like js and css
|
|
468
|
+
results_frameworks.append(f"{path} [Non-Standard File]")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
elif r.status_code == 400:
|
|
472
|
+
if is_framework_asset:
|
|
473
|
+
results_frameworks.append(f"{path} [Asset Error - 400]")
|
|
474
|
+
# get machine services like socket.io that reject simple GET request. cuz socket stinky.
|
|
475
|
+
if "." in path or "/" in path:
|
|
476
|
+
results_services.append(f"{path} [Potential Service - 400]")
|
|
477
|
+
elif is_machine_path:
|
|
478
|
+
results_services.append(f"{path} [Service/API]") #cuz socket
|
|
479
|
+
else:
|
|
480
|
+
results_dead.append(f"400 Bad Request: {path}")
|
|
481
|
+
|
|
482
|
+
elif r.status_code in [403, 404]:
|
|
483
|
+
results_dead.append(f"{r.status_code} Error: {path}")
|
|
484
|
+
elif str(r.status_code).startswith('3'):
|
|
485
|
+
results_30x.append(f"{path} -> {r.headers.get('Location')}")
|
|
486
|
+
except: continue
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if len(results_200) != 0:
|
|
491
|
+
print("\n---- ENDPOINTS FOUND ----")
|
|
492
|
+
for p in results_200: print(f" {p}")
|
|
493
|
+
else:
|
|
494
|
+
print("\n----NO ENDPOITNS FOUND----")
|
|
495
|
+
if len(results_services) != 0:
|
|
496
|
+
print("\n----SERVICES/APIS USED----")
|
|
497
|
+
for p in results_services: print(f" {p}")
|
|
498
|
+
else:
|
|
499
|
+
print("\n----NO SERVICES/APIS FOUND----")
|
|
500
|
+
if len(results_ext) != 0:
|
|
501
|
+
print("\n----EXTERNAL LINKS----")
|
|
502
|
+
for p in results_ext: print(f" {p}")
|
|
503
|
+
else:
|
|
504
|
+
print("\n----NO EXTERNAL LINKS FOUND----")
|
|
505
|
+
if len(results_frameworks) != 0:
|
|
506
|
+
print("\n----WEBSITE FRAMEWORKS----")
|
|
507
|
+
for p in results_frameworks: print(f" {p}")
|
|
508
|
+
else:
|
|
509
|
+
print("\n----NO WEBSITE FRAMEWORKS FOUND----")
|
|
510
|
+
if len(results_30x) != 0:
|
|
511
|
+
print("\n---- REDIRECTS (301/302/307) ----")
|
|
512
|
+
for p in results_30x: print(f" {p}")
|
|
513
|
+
else:
|
|
514
|
+
print("\n----NO REDIRECTS FOUND----")
|
|
515
|
+
# if result contains stuff
|
|
516
|
+
if not args.disable_extra_files:
|
|
517
|
+
if results_fromotherfiles:
|
|
518
|
+
print("\n---- PATHS FROM OTHER FILES ----")
|
|
519
|
+
for entry in results_fromotherfiles:
|
|
520
|
+
print(f" {entry}")
|
|
521
|
+
else:
|
|
522
|
+
print("\n----NO EXTRA PATHS FOUND FROM OTHER FILES----")
|
|
523
|
+
else:
|
|
524
|
+
pass
|
|
525
|
+
if args.show_assets:
|
|
526
|
+
if len(results_assets) != 0:
|
|
527
|
+
print("\n----WEBSITE ASSETS----")
|
|
528
|
+
for p in results_assets: print(f" {p}")
|
|
529
|
+
else:
|
|
530
|
+
print("\n----NO WEBSITE ASSETS FOUND----")
|
|
531
|
+
else:
|
|
532
|
+
pass
|
|
533
|
+
if show_dead:
|
|
534
|
+
if len(results_dead) != 0:
|
|
535
|
+
print("\n---- INACCESSIBLE (Confirmed 404/403) ----")
|
|
536
|
+
for p in results_dead: print(f" {p}")
|
|
537
|
+
else:
|
|
538
|
+
print("\n----NONE INACCESSIBLE 404/403----")
|
|
539
|
+
|
|
540
|
+
print(f"\n--- Scan Summary ---")
|
|
541
|
+
if args.show_assets:
|
|
542
|
+
print(f"Total Accessible Pages: {len(results_200)}\nTotal Assets: {len(results_assets)}\nTotal Inaccessible: {len(results_dead)}\nTotal Redirects: {len(results_30x)}\nTotal Frameworks: {len(results_frameworks)}")
|
|
543
|
+
else:
|
|
544
|
+
print(f"Total Accessible Pages: {len(results_200)}\nTotal Assets: {len(results_assets)} (Assets are hidden, use --show-assets to show them.)\nTotal Inaccessible: {len(results_dead)}\nTotal Redirects: {len(results_30x)}\nTotal Frameworks: {len(results_frameworks)}")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
if args.ratelimit is not None:
|
|
548
|
+
num = args.ratelimit
|
|
549
|
+
test_path = "/" #root dir
|
|
550
|
+
if args.testpath: #idiotproof
|
|
551
|
+
test_path = args.testpath if args.testpath.startswith('/') else '/' + args.testpath
|
|
552
|
+
try:
|
|
553
|
+
check_res = requests.get(urljoin(target, test_path), headers=HEADER, timeout=5, impersonate="chrome120")
|
|
554
|
+
if check_res.status_code in [301, 302, 307, 308, 403, 404]:
|
|
555
|
+
print(f"{test_path} is {check_res.status_code}. Testing on root domain.")
|
|
556
|
+
test_path = "/"
|
|
557
|
+
except:
|
|
558
|
+
test_path = "/"
|
|
559
|
+
|
|
560
|
+
asyncio.run(async_rate_test(urljoin(target, test_path), num))
|
|
561
|
+
|
|
562
|
+
except Exception as e:
|
|
563
|
+
print(f"Main Error: {e}")
|
|
564
|
+
|
|
565
|
+
if __name__ == "__main__":
|
|
566
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "enumendpoint"
|
|
7
|
+
version = "7.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"curl_cffi",
|
|
10
|
+
"beautifulsoup4",
|
|
11
|
+
"playwright",
|
|
12
|
+
"playwright-stealth"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
# pyprojectthing
|
|
17
|
+
enumendpoint = "enumerateendpoint:main"
|