141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import re
|
|
import time
|
|
from html.parser import HTMLParser
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
from redflare.core.models import Finding, ModuleResult, Target
|
|
from .base import Module, ModuleContext
|
|
from .http import request
|
|
|
|
|
|
class SurfaceParser(HTMLParser):
|
|
def __init__(self, base_url: str):
|
|
super().__init__()
|
|
self.base_url = base_url
|
|
self.forms = []
|
|
self.scripts = []
|
|
self.iframes = []
|
|
self.meta_refresh = []
|
|
self._form = None
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
values = dict(attrs)
|
|
if tag == "form":
|
|
self._form = {
|
|
"method": values.get("method", "GET").upper(),
|
|
"action": urljoin(self.base_url, values.get("action", "")),
|
|
"inputs": [],
|
|
}
|
|
self.forms.append(self._form)
|
|
elif tag == "input" and self._form is not None:
|
|
self._form["inputs"].append(
|
|
{
|
|
"name": values.get("name"),
|
|
"type": values.get("type", "text").lower(),
|
|
}
|
|
)
|
|
elif tag == "script" and values.get("src"):
|
|
self.scripts.append(urljoin(self.base_url, values["src"]))
|
|
elif tag == "iframe" and (values.get("src") or values.get("data-src")):
|
|
self.iframes.append(urljoin(self.base_url, values.get("src") or values["data-src"]))
|
|
elif tag == "meta" and values.get("http-equiv", "").lower() == "refresh":
|
|
match = re.search(r"url\s*=\s*(.+)", values.get("content", ""), re.I)
|
|
if match:
|
|
self.meta_refresh.append(urljoin(self.base_url, match.group(1).strip(" '\"")))
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "form":
|
|
self._form = None
|
|
|
|
|
|
class SurfaceAnalysisModule(Module):
|
|
name = "surface_analysis"
|
|
description = "Analyze forms, scripts, iframes, redirects, emails, and encoded targets"
|
|
|
|
def run(self, target: Target, context: ModuleContext) -> ModuleResult:
|
|
started = time.monotonic()
|
|
result = ModuleResult(self.name, target.url)
|
|
try:
|
|
context.emit(target.url, self.name, "progress", "Fetching and parsing application surface")
|
|
response = request(target.url, context.timeout, max_body=1_000_000)
|
|
text = response.body.decode("utf-8", errors="replace")
|
|
parser = SurfaceParser(response.url)
|
|
parser.feed(text)
|
|
emails = sorted(set(re.findall(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", text)))
|
|
encoded_targets = decode_targets(text)
|
|
external_scripts = [
|
|
url for url in parser.scripts if urlparse(url).hostname != target.host
|
|
]
|
|
result.observations.update(
|
|
{
|
|
"forms": parser.forms,
|
|
"scripts": parser.scripts,
|
|
"external_scripts": external_scripts,
|
|
"iframes": parser.iframes,
|
|
"meta_refresh": parser.meta_refresh,
|
|
"emails": emails,
|
|
"decoded_targets": encoded_targets,
|
|
}
|
|
)
|
|
context.emit(
|
|
target.url,
|
|
self.name,
|
|
"info",
|
|
f"forms={len(parser.forms)} scripts={len(parser.scripts)} iframes={len(parser.iframes)} emails={len(emails)}",
|
|
)
|
|
credential_forms = [
|
|
form
|
|
for form in parser.forms
|
|
if any(item.get("type") == "password" for item in form["inputs"])
|
|
]
|
|
if credential_forms:
|
|
result.findings.append(
|
|
Finding(
|
|
context.run_id,
|
|
target.url,
|
|
self.name,
|
|
"credential-surface",
|
|
"Credential-entry form discovered",
|
|
"info",
|
|
0.95,
|
|
"The rendered HTML contains one or more password-entry forms for assessment context.",
|
|
{"forms": credential_forms},
|
|
["forms", "authentication"],
|
|
)
|
|
)
|
|
if parser.meta_refresh:
|
|
result.findings.append(
|
|
Finding(
|
|
context.run_id,
|
|
target.url,
|
|
self.name,
|
|
"client-redirect",
|
|
"Meta-refresh redirect discovered",
|
|
"info",
|
|
0.9,
|
|
"The page contains a client-side meta-refresh redirect.",
|
|
{"destinations": parser.meta_refresh},
|
|
["redirect", "surface"],
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
result.status = "error"
|
|
result.errors.append(f"{type(exc).__name__}: {exc}")
|
|
result.duration_seconds = round(time.monotonic() - started, 4)
|
|
return result
|
|
|
|
|
|
def decode_targets(text: str) -> list[str]:
|
|
decoded = []
|
|
for encoded in re.findall(r"(?:target|url)=([A-Za-z0-9+/]{8,}={0,2})", text):
|
|
try:
|
|
value = base64.b64decode(encoded).decode("utf-8")
|
|
except Exception:
|
|
continue
|
|
if value.startswith(("http://", "https://")):
|
|
decoded.append(value)
|
|
return sorted(set(decoded))
|