File size: 8,665 Bytes
d543fc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""
admin_panel_scanner.py — Admin Panel & Exposed Dashboard Scanner
================================================================
PHASE 1: Baseline filtering added — only reports panels that are NOT the
         site's generic SPA catch-all response.
PHASE 2: Content-signature validation for product-specific panels.

Probes a targeted wordlist of admin, monitoring, and developer dashboards
that are often left exposed. More targeted than the generic directory scanner.
"""
import urllib.request, urllib.error
from scanners.base_scanner import BaseScanner
from scanners.core.signatures import matches_signature

ADMIN_PATHS = [
    # Generic admin panels
    "/admin", "/admin/", "/admin/login", "/admin/dashboard",
    "/_admin", "/administrator", "/admincp", "/admin1", "/admin2",
    "/backend", "/backend/login", "/manage", "/management",
    "/control", "/controlpanel", "/cp", "/cpanel",
    # PHP tooling
    "/phpmyadmin", "/pma", "/phpMyAdmin", "/phpmyadmin/", "/mysql",
    "/adminer", "/adminer.php", "/db", "/database",
    # Python/Django
    "/django-admin", "/django/admin", "/_admin/",
    # Java / Spring / JEE
    "/manager", "/manager/html", "/host-manager", "/console",
    "/actuator", "/actuator/health", "/actuator/env",
    "/actuator/beans", "/actuator/mappings", "/actuator/info",
    "/jolokia", "/jolokia/list", "/druid", "/druid/login.html",
    # CI/CD & DevOps
    "/jenkins", "/jenkins/", "/jenkins/login",
    "/gitlab", "/gitlab/users/sign_in",
    "/sonarqube", "/sonar",
    # Monitoring / Observability
    "/grafana", "/grafana/login",
    "/kibana", "/kibana/app/kibana",
    "/prometheus", "/metrics", "/_prometheus/metrics",
    "/jaeger", "/zipkin",
    # Messaging & Queues
    "/rabbitmq", "/rabbitmq-management",
    "/activemq", "/activemq/admin",
    "/kafka", "/kafka-ui",
    # Container / Cloud
    "/portainer", "/rancher", "/kubernetes",
    "/_cluster/health", "/_cat/nodes",  # Elasticsearch
    # CMS / CRM
    "/wp-admin", "/wp-login.php",
    "/typo3", "/typo3/backend",
    "/joomla/administrator", "/index.php?option=com_admin",
    # Misc
    "/setup", "/setup.php", "/install", "/install.php",
    "/config", "/config.php", "/.env", "/server-status",
    "/server-info", "/status", "/info.php", "/phpinfo.php",
]

ADMIN_KEYWORDS = [
    "login", "dashboard", "admin", "username", "password",
    "sign in", "control panel", "management", "welcome back",
    "<form", "Log In", "Sign In",
]

# Map paths → signature keys for product-specific validation
_PATH_SIG_MAP: dict[str, str] = {
    "/jenkins":           "jenkins",
    "/jenkins/":          "jenkins",
    "/jenkins/login":     "jenkins",
    "/grafana":           "grafana",
    "/grafana/login":     "grafana",
    "/kibana":            "kibana",
    "/kibana/app/kibana": "kibana",
    "/prometheus":        "prometheus",
    "/phpmyadmin":        "phpmyadmin",
    "/pma":               "phpmyadmin",
    "/phpMyAdmin":        "phpmyadmin",
    "/phpmyadmin/":       "phpmyadmin",
    "/adminer":           "adminer",
    "/adminer.php":       "adminer",
    "/phpinfo.php":       "phpinfo",
    "/info.php":          "phpinfo",
    "/wp-login.php":      "wp_login",
    "/wp-admin":          "wp_login",
    "/_cluster/health":   "elasticsearch",
    "/actuator":          "spring_actuator",
    "/actuator/health":   "spring_actuator",
    "/actuator/env":      "spring_actuator_env",
    "/portainer":         "portainer",
}


class AdminPanelScanner(BaseScanner):
    SCANNER_NAME = "Admin Panel & Dashboard Scanner"
    _SCANNER_KEY = "admin_panel"

    def __init__(self, scan_id, target, domain, **kwargs):
        super().__init__(scan_id, target, domain, **kwargs)

    def run(self) -> list:
        self.log("INFO", f"[AdminPanel] Probing {len(ADMIN_PATHS)} admin/dashboard paths on {self.target}...")
        base = self.target.rstrip("/")
        found = []

        for path in ADMIN_PATHS:
            url = base + path
            status, body = self._probe(url)

            if status == 200 and body is not None:
                # PHASE 1: Suppress if response is the site's SPA/404 catch-all
                if self._is_baseline(status, body):
                    self.log("INFO", f"[AdminPanel] SUPPRESSED (baseline match, {len(body)}b): {url}")
                    continue

                # Minimum content threshold — avoid near-empty redirect bodies
                if len(body) < 200:
                    self.log("INFO", f"[AdminPanel] SKIPPED (body too small, {len(body)}b): {url}")
                    continue

                # PHASE 2: Product-specific signature check
                sig_key = _PATH_SIG_MAP.get(path)
                if sig_key:
                    if not matches_signature(sig_key, body, log_fn=lambda m: self.log("INFO", m), url=url):
                        continue
                else:
                    # Generic admin check: must contain admin keywords AND a form element
                    body_lower = body.lower()
                    has_keyword = any(kw.lower() in body_lower for kw in ADMIN_KEYWORDS)
                    has_form = "<form" in body_lower
                    if not (has_keyword and has_form):
                        self.log("INFO", f"[AdminPanel] SKIPPED (no keyword+form match): {url}")
                        continue

                found.append((path, status, len(body)))
                self.log("WARNING", f"[AdminPanel] FOUND: {url} ({status}, {len(body)}b)")

            elif status in (401, 403):
                # Access denied — still confirms presence of a panel
                found.append((path, status, 0))
                self.log("INFO", f"[AdminPanel] Protected (HTTP {status}): {url}")

        if found:
            public = [(p, s, b) for p, s, b in found if s == 200]
            protected = [(p, s, b) for p, s, b in found if s in (401, 403)]

            if public:
                self.add_vuln(
                    title=f"Unauthenticated Admin Panels Found ({len(public)})",
                    severity="Critical",
                    category="Exposed Admin Panel",
                    cvss_score=9.8,
                    confidence="Confirmed",
                    description=(
                        f"The following admin/dashboard endpoints are publicly accessible (HTTP 200) "
                        f"without authentication:\n\n" +
                        "\n".join(f"- `{base + p}` ({b} bytes)" for p, s, b in public[:10])
                    ),
                    remediation=(
                        "1. Immediately restrict admin panels to internal IPs / VPN only.\n"
                        "2. Require MFA on all administrative interfaces.\n"
                        "3. Remove development tools (phpMyAdmin, Adminer) from production.\n"
                        "4. Disable Spring Actuator endpoints: management.endpoints.enabled-by-default=false"
                    ),
                    evidence=f"{len(public)} unauthenticated admin path(s) found, verified against site baseline.",
                )
            if protected:
                self.add_vuln(
                    title=f"Admin Panels Discovered But Access-Controlled ({len(protected)})",
                    severity="Medium",
                    category="Exposed Admin Panel",
                    cvss_score=5.3,
                    confidence="Likely",
                    description=(
                        f"Admin panels exist but return 401/403:\n\n" +
                        "\n".join(f"- `{base + p}` (HTTP {s})" for p, s, b in protected[:10]) +
                        "\n\nWhile protected, their existence aids attacker reconnaissance "
                        "and they remain vulnerable to credential stuffing."
                    ),
                    remediation=(
                        "Restrict admin paths to internal networks. Return 404 (not 403) "
                        "for admin paths when accessed externally to reduce information disclosure."
                    ),
                )
        else:
            self.log("SUCCESS", "[AdminPanel] No admin panels or dashboards found.")
        return self.vulns

    def _probe(self, url):
        try:
            req = urllib.request.Request(url, headers=self._make_headers())
            with urllib.request.urlopen(req, timeout=4, context=self.get_ssl_context()) as r:
                return r.status, r.read().decode("utf-8", errors="ignore")
        except urllib.error.HTTPError as e:
            return e.code, ""
        except Exception as e:
            self.log("ERROR", f"[AdminPanel] _probe error: {e}")
            return 0, ""