Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions configs/local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ workflow:
tools: ['Wafw00f']
order: 4
- moduleName: scan
tools: [ 'DNSTwister', 'Nuclei', 'Corsy']
tools: [ 'DNSTwister', 'Nuclei', 'Corsy', 'LoginPageScanner']
order: 5
- moduleName: secretscanner
tools: ['SecretScanner','GithubScanner']
Expand Down Expand Up @@ -55,7 +55,7 @@ workflow:
tools: ['Wafw00f']
order: 4
- moduleName: scan
tools: [ 'DNSTwister', 'Nuclei', 'NucleiInfo', 'Corsy']
tools: [ 'DNSTwister', 'Nuclei', 'NucleiInfo', 'Corsy', 'LoginPageScanner']
order: 5
- moduleName: secretscanner
tools: ['SecretScanner','GithubScanner']
Expand Down
107 changes: 107 additions & 0 deletions mantis/modules/scan/LoginPageScanner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import json
import logging
from mantis.tool_base_classes.apiScanner import APIScanner
from mantis.utils.tool_utils import get_assets_grouped_by_type, get_assets_with_non_empty_fields
from mantis.constants import ASSET_TYPE_SUBDOMAIN, ASSET_TYPE_TLD
from mantis.models.args_model import ArgsModel
from mantis.utils.crud_utils import CrudUtils
from mantis.utils.login_detector import LoginDetector

'''
LoginPageScanner module scans active subdomains, hosts, and paths for exposed login interfaces,
password inputs, and authentication endpoints.
Output: Findings of type informational
Information inserted in DB:
- host / url
- title: Exposed Login Page / Authentication Surface Detected
- description: Details of detected login surface, password fields, indicators
- severity: info
- type: informational
'''

class LoginPageScanner(APIScanner):

DEFAULT_AUTH_PATHS = [
"",
"/login",
"/signin",
"/admin",
"/auth",
"/dashboard",
"/portal",
"/user/login",
"/api/v1/auth"
]

async def get_api_calls(self, args: ArgsModel):
self.asset_api_list = []
self.scannerName = type(self).__name__
self.org = args.org

# Fetch subdomains and active hosts
subdomains = await get_assets_grouped_by_type(self, args, ASSET_TYPE_SUBDOMAIN)
active_assets = await get_assets_with_non_empty_fields(self, args, "active_hosts")

hosts_to_scan = set()

if subdomains:
for sub in subdomains:
hosts_to_scan.add(f"https://{sub}")
hosts_to_scan.add(f"http://{sub}")

if active_assets:
for item in active_assets:
if "active_hosts" in item and item["active_hosts"]:
for host_list in item["active_hosts"]:
for host in host_list:
if host.startswith("http://") or host.startswith("https://"):
hosts_to_scan.add(host)
else:
hosts_to_scan.add(f"https://{host}")

for base_url in hosts_to_scan:
base_url = base_url.rstrip("/")
for path in self.DEFAULT_AUTH_PATHS:
target_url = f"{base_url}{path}"
self.asset_api_list.append((target_url, None, None, base_url))

return [(self, "GET")]

def parse_response(self, response):
findings = []
self.finding_type = "informational"

if response and response.status_code in range(200, 399):
url = response.url if hasattr(response, 'url') else ""
html_content = response.text if hasattr(response, 'text') else ""

login_finding = LoginDetector.detect_login_surface(
url=url,
html_body=html_content,
status_code=response.status_code
)

if login_finding:
finding_dict = {
"title": login_finding["title"],
"type": self.finding_type,
"severity": login_finding["severity"],
"description": login_finding["description"],
"url": url,
"org": self.org,
"info": {
"indicators": login_finding.get("indicators", []),
"confidence": login_finding.get("confidence", "LOW"),
"has_password_field": login_finding.get("has_password_field", False)
},
"others": {
"status_code": response.status_code
}
}
findings.append(finding_dict)

return findings

async def db_operations(self, output_dict, asset=None):
if output_dict:
await CrudUtils.insert_findings(self, asset, output_dict, self.finding_type)
8 changes: 7 additions & 1 deletion mantis/utils/base_request.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import requests
import logging
from requests import Timeout
from retry import retry
try:
from retry import retry
except ImportError:
def retry(*args, **kwargs):
def decorator(func):
return func
return decorator


class BaseRequestExecutor:
Expand Down
159 changes: 159 additions & 0 deletions mantis/utils/login_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import re
import logging
from typing import Dict, List, Optional, Tuple, Any

class LoginDetector:
"""
Utility for detecting login forms, password inputs, authentication endpoints,
and exposed login surfaces across web assets, HTTP responses, HTML bodies, and URL paths.
"""

# Common URL path keywords indicating login, authentication, admin, or SSO portals
AUTH_PATH_PATTERNS = [
r'(?:^|/)(?:login|signin|sign-in|authenticate|auth|session|oauth|sso|cas|saml)(?:$|[/?#._])',
r'(?:^|/)(?:admin|administrator|admin-login|wp-login|user/login|cpanel|webmail)(?:$|[/?#._])',
r'(?:^|/)(?:portal|dashboard|authn|idp|keycloak|auth0)(?:$|[/?#._])',
r'(?:^|/)(?:api/v\d+/(?:auth|login|token|oauth|authenticate))(?:$|[/?#._])',
]

# HTML Input field patterns indicating login / authentication forms
PASSWORD_INPUT_PATTERN = re.compile(
r'<input[^>]+type\s*=\s*["\']password["\'][^>]*>',
re.IGNORECASE
)

USERNAME_EMAIL_INPUT_PATTERN = re.compile(
r'<input[^>]+(?:name|id|autocomplete|placeholder)\s*=\s*["\'](?:username|user|email|login|user_login|userid|account)["\'][^>]*>',
re.IGNORECASE
)

FORM_ACTION_AUTH_PATTERN = re.compile(
r'<form[^>]+action\s*=\s*["\'][^"\']*(?:login|signin|sign-in|auth|session|authenticate|token)[^"\']*["\'][^>]*>',
re.IGNORECASE
)

LOGIN_BUTTON_TEXT_PATTERN = re.compile(
r'<(?:button|input)[^>]+(?:type\s*=\s*["\']submit["\'][^>]*)?(?:value\s*=\s*["\'](?:Log\s*In|Sign\s*In|Login|Submit|Authorize)["\'][^>]*>|>[^<]*(?:Log\s*In|Sign\s*In|Login)[^<]*</button>)',
re.IGNORECASE
)

OAUTH_SSO_INDICATOR_PATTERN = re.compile(
r'(?:Sign\s*in\s*with\s*(?:Google|Microsoft|Apple|GitHub|Okta|SAML|SSO)|OAuth\s*2\.0\s*Authorization\s*Server)',
re.IGNORECASE
)

@classmethod
def matches_auth_path(cls, url_or_path: str) -> bool:
"""Check if a URL or path matches known login/auth endpoint patterns."""
if not url_or_path:
return False
for pattern in cls.AUTH_PATH_PATTERNS:
if re.search(pattern, url_or_path, re.IGNORECASE):
return True
return False

@classmethod
def has_password_field(cls, html_content: str) -> bool:
"""Check if HTML content contains an input field of type password."""
if not html_content:
return False
return bool(cls.PASSWORD_INPUT_PATTERN.search(html_content))

@classmethod
def analyze_html_content(cls, html_content: str, url: str = "") -> Dict[str, Any]:
"""
Analyze HTML content for login surfaces.
Returns a dictionary containing detection metadata and confidence score.
"""
if not html_content:
return {
"is_login_page": False,
"confidence": "LOW",
"indicators": [],
"has_password_field": False,
"has_auth_form": False,
"has_auth_endpoint": False
}

indicators: List[str] = []
score = 0

# Check password input field
has_password = bool(cls.PASSWORD_INPUT_PATTERN.search(html_content))
if has_password:
indicators.append("Password input field detected (<input type='password'>)")
score += 50

# Check username / email field
has_username = bool(cls.USERNAME_EMAIL_INPUT_PATTERN.search(html_content))
if has_username:
indicators.append("Username/Email input field detected")
score += 20

# Check form action
has_auth_form = bool(cls.FORM_ACTION_AUTH_PATTERN.search(html_content))
if has_auth_form:
indicators.append("Form action targeting auth/login endpoint detected")
score += 25

# Check login button text
has_login_btn = bool(cls.LOGIN_BUTTON_TEXT_PATTERN.search(html_content))
if has_login_btn:
indicators.append("Login/Sign-in submit button detected")
score += 15

# Check OAuth/SSO indicators
has_sso = bool(cls.OAUTH_SSO_INDICATOR_PATTERN.search(html_content))
if has_sso:
indicators.append("OAuth/SSO authorization indicator detected")
score += 20

# Check URL path
has_auth_path = cls.matches_auth_path(url)
if has_auth_path:
indicators.append("URL path matches authentication endpoint pattern")
score += 20

is_login = has_password or (score >= 40)

if score >= 60:
confidence = "HIGH"
elif score >= 30:
confidence = "MEDIUM"
else:
confidence = "LOW"

return {
"is_login_page": is_login,
"confidence": confidence,
"score": score,
"indicators": indicators,
"has_password_field": has_password,
"has_auth_form": has_auth_form,
"has_auth_endpoint": has_auth_path,
"has_sso": has_sso
}

@classmethod
def detect_login_surface(cls, url: str, html_body: Optional[str] = None, status_code: Optional[int] = 200) -> Optional[Dict[str, Any]]:
"""
Scan a given URL and its response body to identify login surfaces.
Returns a finding dictionary if detected, otherwise None.
"""
if status_code and status_code >= 400:
return None

analysis = cls.analyze_html_content(html_body or "", url=url)

if analysis["is_login_page"] or (analysis["has_auth_endpoint"] and status_code == 200):
return {
"title": "Exposed Login Page / Authentication Surface Detected",
"type": "informational",
"severity": "info",
"url": url,
"description": f"Exposed authentication surface detected at {url}. " + "; ".join(analysis["indicators"]),
"indicators": analysis["indicators"],
"confidence": analysis["confidence"],
"has_password_field": analysis["has_password_field"],
}
return None
92 changes: 92 additions & 0 deletions tests/test_login_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import unittest
from unittest.mock import MagicMock
from mantis.utils.login_detector import LoginDetector
from mantis.modules.scan.LoginPageScanner import LoginPageScanner

class TestLoginDetector(unittest.TestCase):

def test_matches_auth_path(self):
self.assertTrue(LoginDetector.matches_auth_path("https://example.com/login"))
self.assertTrue(LoginDetector.matches_auth_path("https://example.com/signin"))
self.assertTrue(LoginDetector.matches_auth_path("https://example.com/admin/dashboard"))
self.assertTrue(LoginDetector.matches_auth_path("https://example.com/api/v1/auth"))
self.assertTrue(LoginDetector.matches_auth_path("https://example.com/oauth/authorize"))
self.assertFalse(LoginDetector.matches_auth_path("https://example.com/about-us"))
self.assertFalse(LoginDetector.matches_auth_path("https://example.com/pricing"))

def test_has_password_field(self):
html_with_password = """
<html>
<body>
<form action="/login" method="POST">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Sign In</button>
</form>
</body>
</html>
"""
self.assertTrue(LoginDetector.has_password_field(html_with_password))

html_without_password = """
<html>
<body>
<h1>Welcome to our blog</h1>
<input type="text" name="search" placeholder="Search..." />
</body>
</html>
"""
self.assertFalse(LoginDetector.has_password_field(html_without_password))

def test_analyze_html_content(self):
login_html = """
<html>
<head><title>Admin Login</title></head>
<body>
<form action="/api/v1/authenticate" method="POST">
<label>Email</label>
<input type="email" name="email" id="email" />
<label>Password</label>
<input type="password" name="password" id="password" />
<input type="submit" value="Log In" />
</form>
</body>
</html>
"""
result = LoginDetector.analyze_html_content(login_html, url="https://example.com/admin")
self.assertTrue(result["is_login_page"])
self.assertEqual(result["confidence"], "HIGH")
self.assertTrue(result["has_password_field"])
self.assertTrue(result["has_auth_form"])
self.assertTrue(result["has_auth_endpoint"])
self.assertGreaterEqual(len(result["indicators"]), 3)

def test_detect_login_surface(self):
login_html = """
<form action="/auth/login">
<input type="password" name="pwd" />
</form>
"""
finding = LoginDetector.detect_login_surface("https://app.example.com/login", html_body=login_html, status_code=200)
self.assertIsNotNone(finding)
self.assertEqual(finding["title"], "Exposed Login Page / Authentication Surface Detected")
self.assertEqual(finding["type"], "informational")
self.assertTrue(finding["has_password_field"])

def test_login_page_scanner_parse_response(self):
scanner = LoginPageScanner()
scanner.org = "test_org"

mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://admin.example.com/login"
mock_response.text = '<form><input type="password" name="pass"/><button>Log In</button></form>'

findings = scanner.parse_response(mock_response)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["title"], "Exposed Login Page / Authentication Surface Detected")
self.assertEqual(findings[0]["org"], "test_org")
self.assertEqual(findings[0]["type"], "informational")

if __name__ == "__main__":
unittest.main()