Upload files to "redflare/modules"

This commit is contained in:
2026-06-21 17:54:15 +00:00
parent 2230d24e5f
commit 3f2bb5309f
5 changed files with 677 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import time
from redflare.core.models import Finding, ModuleResult, Target
from .base import Module, ModuleContext
from .http import request
SECURITY_HEADERS = {
"strict-transport-security": "HSTS",
"content-security-policy": "Content Security Policy",
"x-content-type-options": "MIME sniffing protection",
"referrer-policy": "Referrer Policy",
"permissions-policy": "Permissions Policy",
}
class HeaderModule(Module):
name = "http_headers"
description = "Inspect HTTP status, redirects, server metadata, and security headers"
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", f"Requesting {target.url}")
response = request(target.url, context.timeout, method="GET", max_body=200_000)
missing = [header for header in SECURITY_HEADERS if header not in response.headers]
result.observations.update(
{
"status": response.status,
"final_url": response.url,
"headers": response.headers,
"body_bytes_sampled": len(response.body),
"missing_security_headers": missing,
}
)
context.emit(
target.url,
self.name,
"info",
f"HTTP {response.status}; final={response.url}; sampled={len(response.body)} bytes",
)
if missing:
result.findings.append(
Finding(
context.run_id,
target.url,
self.name,
"security-headers",
"Common security headers are missing",
"low",
0.95,
"The response omitted: " + ", ".join(missing),
{"status": response.status, "missing": missing},
["headers", "hardening"],
)
)
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
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass
class HTTPResponse:
url: str
status: int
headers: dict[str, str]
body: bytes
class ScopedRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, allowed_origin: tuple[str, int]):
super().__init__()
self.allowed_origin = allowed_origin
def redirect_request(self, req, fp, code, msg, headers, newurl):
parsed = urlparse(newurl)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if (parsed.hostname, port) != self.allowed_origin:
return None
return super().redirect_request(req, fp, code, msg, headers, newurl)
def request(
url: str,
timeout: float,
method: str = "GET",
max_body: int = 1_000_000,
*,
data: bytes | None = None,
headers: dict[str, str] | None = None,
allowed_origin: tuple[str, int] | None = None,
) -> HTTPResponse:
request_headers = {"User-Agent": "REDflare-v2/2.1 authorized-assessment"}
request_headers.update(headers or {})
req = urllib.request.Request(url, data=data, method=method, headers=request_headers)
context = ssl.create_default_context()
try:
if allowed_origin:
opener = urllib.request.build_opener(
ScopedRedirectHandler(allowed_origin), urllib.request.HTTPSHandler(context=context)
)
response = opener.open(req, timeout=timeout)
else:
response = urllib.request.urlopen(req, timeout=timeout, context=context)
except urllib.error.HTTPError as exc:
response = exc
with response:
body = b"" if method == "HEAD" else response.read(max_body)
return HTTPResponse(
url=response.geturl(),
status=int(response.status),
headers={key.lower(): value for key, value in response.headers.items()},
body=body,
)
+407
View File
@@ -0,0 +1,407 @@
from __future__ import annotations
import json
import re
import time
from collections import deque
from html.parser import HTMLParser
from typing import Any
from urllib.parse import urljoin, urlparse
from redflare.core.models import Finding, ModuleResult, Target
from .base import Module, ModuleContext
from .http import request
OPENAPI_PATHS = (
"/openapi.json",
"/swagger.json",
"/api/openapi.json",
"/api/swagger.json",
"/v3/api-docs",
)
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
ROUTE_LITERAL = re.compile(r"[\"']((?:https?://[^\"']+)|(?:/[A-Za-z0-9_~!$&'()*+,;=:@%./?{}-]{2,}))['\"]")
FETCH_CALL = re.compile(r"fetch\s*\(\s*[\"']([^\"']+)[\"']", re.I)
AXIOS_CALL = re.compile(r"axios\.(get|post|put|patch|delete)\s*\(\s*[\"']([^\"']+)[\"']", re.I)
class MappingParser(HTMLParser):
def __init__(self, base_url: str):
super().__init__()
self.base_url = base_url
self.links: list[str] = []
self.scripts: list[str] = []
self.forms: list[dict[str, Any]] = []
self._form: dict[str, Any] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
values = dict(attrs)
if tag in {"a", "area"} and values.get("href"):
self.links.append(urljoin(self.base_url, values["href"] or ""))
elif tag == "script" and values.get("src"):
self.scripts.append(urljoin(self.base_url, values["src"] or ""))
elif tag == "form":
self._form = {
"url": urljoin(self.base_url, values.get("action") or self.base_url),
"method": (values.get("method") or "GET").upper(),
"content_type": values.get("enctype") or "application/x-www-form-urlencoded",
"parameters": [],
}
self.forms.append(self._form)
elif tag in {"input", "select", "textarea", "button"} and self._form is not None:
name = values.get("name")
if name:
self._form["parameters"].append(
{
"name": name,
"location": "query" if self._form["method"] == "GET" else "body",
"required": "required" in values,
"data_type": values.get("type") or tag,
}
)
def handle_endtag(self, tag: str) -> None:
if tag == "form":
self._form = None
class ApplicationMappingModule(Module):
name = "application_mapping"
description = "Build a shared endpoint graph from pages, forms, JavaScript, API schemas, and optional GraphQL metadata"
def run(self, target: Target, context: ModuleContext) -> ModuleResult:
started = time.monotonic()
result = ModuleResult(self.name, target.url)
pages_visited: list[str] = []
scripts_seen: set[str] = set()
schema_candidates = {urljoin(target.url.rstrip("/") + "/", path.lstrip("/")) for path in OPENAPI_PATHS}
queue = deque([(target.url, 0, "seed")])
queued = {target.url}
try:
while queue and len(pages_visited) < context.max_crawl_pages:
page_url, depth, source = queue.popleft()
if not self._same_origin(target, page_url):
continue
context.emit(target.url, self.name, "progress", f"Mapping {page_url}")
try:
response = request(
page_url,
context.timeout,
max_body=1_000_000,
allowed_origin=(target.host, target.port),
)
except Exception as exc:
result.errors.append(f"{page_url}: {type(exc).__name__}: {exc}")
continue
pages_visited.append(page_url)
content_type = response.headers.get("content-type", "")
context.surface_graph.add_endpoint(
target.url,
response.url,
method="GET",
source=source,
content_type=content_type,
status=response.status,
)
if not self._same_origin(target, response.url):
continue
if "json" in content_type and self._looks_like_schema(response.body):
schema_candidates.add(response.url)
if "html" not in content_type and b"<html" not in response.body[:1000].lower():
continue
parser = MappingParser(response.url)
parser.feed(response.body.decode("utf-8", errors="replace"))
for form in parser.forms:
if not self._same_origin(target, form["url"]):
continue
context.surface_graph.add_endpoint(
target.url,
form["url"],
method=form["method"],
source="html-form",
content_type=form["content_type"],
authentication="credential-form" if any(
item["data_type"] == "password" for item in form["parameters"]
) else "unknown",
parameters=form["parameters"],
)
context.surface_graph.add_edge(target.url, response.url, form["url"], "form")
for link in parser.links:
if not self._same_origin(target, link):
continue
context.surface_graph.add_endpoint(target.url, link, source="html-link")
context.surface_graph.add_edge(target.url, response.url, link, "link")
if depth < context.max_crawl_depth and link not in queued:
queued.add(link)
queue.append((link, depth + 1, "crawl"))
for script in parser.scripts:
if self._same_origin(target, script):
scripts_seen.add(script)
context.surface_graph.add_endpoint(target.url, script, source="html-script")
context.surface_graph.add_edge(target.url, response.url, script, "script")
self._map_javascript(target, context, scripts_seen, result)
self._map_openapi(target, context, schema_candidates, result)
if context.graphql_introspection:
self._map_graphql(target, context, result)
snapshot = context.surface_graph.snapshot()["targets"].get(target.url, {})
endpoints = snapshot.get("endpoints", [])
result.observations.update(
{
"pages_visited": pages_visited,
"scripts_analyzed": sorted(scripts_seen),
"endpoint_count": len(endpoints),
"document_count": len(snapshot.get("documents", [])),
}
)
result.findings.append(
Finding(
context.run_id,
target.url,
self.name,
"attack-surface-inventory",
"Application attack surface mapped",
"info",
1.0,
"REDflare built a deduplicated endpoint inventory from authorized application evidence.",
{
"endpoints": len(endpoints),
"pages_visited": len(pages_visited),
"scripts_analyzed": len(scripts_seen),
"documents": len(snapshot.get("documents", [])),
},
["mapping", "inventory", "web"],
)
)
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
@staticmethod
def _same_origin(target: Target, url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or parsed.hostname != target.host:
return False
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return port == target.port
@staticmethod
def _looks_like_schema(body: bytes) -> bool:
sample = body[:2000].lower()
return b'"openapi"' in sample or b'"swagger"' in sample
def _map_javascript(
self, target: Target, context: ModuleContext, scripts: set[str], result: ModuleResult
) -> None:
for script_url in sorted(scripts)[: context.max_scripts]:
try:
response = request(
script_url,
context.timeout,
max_body=1_000_000,
allowed_origin=(target.host, target.port),
)
except Exception as exc:
result.errors.append(f"{script_url}: {type(exc).__name__}: {exc}")
continue
text = response.body.decode("utf-8", errors="replace")
routes: dict[str, str] = {}
for value in FETCH_CALL.findall(text):
routes[value] = "GET"
for method, value in AXIOS_CALL.findall(text):
routes[value] = method.upper()
for value in ROUTE_LITERAL.findall(text):
routes.setdefault(value, "GET")
for value, method in routes.items():
url = urljoin(script_url, value)
if not self._same_origin(target, url):
continue
context.surface_graph.add_endpoint(
target.url, url, method=method, source="javascript-route"
)
context.surface_graph.add_edge(target.url, script_url, url, "javascript-route")
def _map_openapi(
self, target: Target, context: ModuleContext, candidates: set[str], result: ModuleResult
) -> None:
for schema_url in sorted(candidates)[: context.max_schema_documents]:
if not self._same_origin(target, schema_url):
continue
try:
response = request(
schema_url,
context.timeout,
max_body=2_000_000,
allowed_origin=(target.host, target.port),
)
document = json.loads(response.body.decode("utf-8"))
except Exception:
continue
if not isinstance(document, dict) or not (document.get("openapi") or document.get("swagger")):
continue
version = str(document.get("openapi") or document.get("swagger"))
context.surface_graph.add_document(
target.url,
{"kind": "openapi", "url": schema_url, "version": version, "title": document.get("info", {}).get("title")},
)
base_url = self._openapi_base(schema_url, document)
global_security = document.get("security")
for path, path_item in document.get("paths", {}).items():
if not isinstance(path_item, dict):
continue
shared_parameters = path_item.get("parameters", [])
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS or not isinstance(operation, dict):
continue
url = urljoin(base_url.rstrip("/") + "/", str(path).lstrip("/"))
if not self._same_origin(target, url):
continue
parameters = self._openapi_parameters(
shared_parameters + operation.get("parameters", []), document
)
content_types = set(operation.get("consumes", document.get("consumes", [])))
request_body = operation.get("requestBody", {})
content_types.update(request_body.get("content", {}).keys())
for media in request_body.get("content", {}).values():
if isinstance(media, dict):
parameters.extend(
self._schema_parameters(media.get("schema", {}), document, "body")
)
security = operation.get("security", global_security)
auth_schemes = sorted(
{name for requirement in security or [] for name in requirement}
)
auth = "none" if security == [] else ",".join(auth_schemes) if auth_schemes else "unknown"
for index, content_type in enumerate(sorted(content_types) or [None]):
context.surface_graph.add_endpoint(
target.url,
url,
method=method,
source="openapi",
content_type=content_type,
authentication=auth,
parameters=parameters if index == 0 else None,
metadata={"operation_id": operation.get("operationId"), "schema": schema_url},
)
@staticmethod
def _openapi_base(schema_url: str, document: dict[str, Any]) -> str:
servers = document.get("servers") or []
if servers and isinstance(servers[0], dict) and servers[0].get("url"):
return urljoin(schema_url, servers[0]["url"])
if document.get("host"):
scheme = (document.get("schemes") or [urlparse(schema_url).scheme])[0]
return f"{scheme}://{document['host']}{document.get('basePath', '/')}"
parsed = urlparse(schema_url)
return f"{parsed.scheme}://{parsed.netloc}/"
@classmethod
def _openapi_parameters(
cls, parameters: list[Any], document: dict[str, Any]
) -> list[dict[str, Any]]:
values = []
for parameter in parameters:
if not isinstance(parameter, dict) or not parameter.get("name"):
continue
schema = parameter.get("schema") or {}
values.append(
{
"name": parameter["name"],
"location": parameter.get("in", "unknown"),
"required": bool(parameter.get("required")),
"data_type": schema.get("type") or parameter.get("type") or "unknown",
}
)
if parameter.get("in") == "body":
values.extend(cls._schema_parameters(schema, document, "body"))
return values
@classmethod
def _schema_parameters(
cls, schema: dict[str, Any], document: dict[str, Any], location: str
) -> list[dict[str, Any]]:
schema = cls._resolve_schema(schema, document)
required = set(schema.get("required", []))
values = []
for name, details in schema.get("properties", {}).items():
if not isinstance(details, dict):
continue
details = cls._resolve_schema(details, document)
values.append(
{
"name": name,
"location": location,
"required": name in required,
"data_type": details.get("type") or ("object" if details.get("properties") else "unknown"),
}
)
return values
@staticmethod
def _resolve_schema(schema: dict[str, Any], document: dict[str, Any]) -> dict[str, Any]:
reference = schema.get("$ref") if isinstance(schema, dict) else None
if not isinstance(reference, str) or not reference.startswith("#/"):
return schema if isinstance(schema, dict) else {}
current: Any = document
for part in reference[2:].split("/"):
if not isinstance(current, dict):
return {}
current = current.get(part.replace("~1", "/").replace("~0", "~"))
return current if isinstance(current, dict) else {}
def _map_graphql(self, target: Target, context: ModuleContext, result: ModuleResult) -> None:
candidates = {
urljoin(target.url.rstrip("/") + "/", "graphql"),
*(
url for url in context.surface_graph.endpoint_urls(target.url)
if "graphql" in urlparse(url).path.lower()
),
}
query = "{__schema{queryType{name} mutationType{name} types{name kind fields{name args{name}}}}}"
payload = json.dumps({"query": query}).encode("utf-8")
for url in sorted(candidates):
if not self._same_origin(target, url):
continue
try:
response = request(
url,
context.timeout,
method="POST",
max_body=2_000_000,
data=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
allowed_origin=(target.host, target.port),
)
document = json.loads(response.body.decode("utf-8"))
schema = document.get("data", {}).get("__schema")
except Exception:
continue
if not schema:
continue
operations: list[str] = []
for type_item in schema.get("types", []):
if type_item.get("name") in {
(schema.get("queryType") or {}).get("name"),
(schema.get("mutationType") or {}).get("name"),
}:
operations.extend(field.get("name") for field in type_item.get("fields", []) if field.get("name"))
context.surface_graph.add_endpoint(
target.url,
url,
method="POST",
source="graphql-introspection",
content_type="application/json",
status=response.status,
metadata={"graphql_operations": sorted(set(operations))},
)
context.surface_graph.add_document(
target.url,
{"kind": "graphql", "url": url, "operations": len(set(operations))},
)
result.observations["graphql"] = {"url": url, "operations": sorted(set(operations))}
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import socket
import ssl
import time
from redflare.core.models import ModuleResult, Target
from .base import Module, ModuleContext
class PassiveReconModule(Module):
name = "passive_recon"
description = "Resolve DNS and collect TLS certificate metadata"
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", f"Resolving {target.host}")
addresses = sorted({item[4][0] for item in socket.getaddrinfo(target.host, target.port)})
result.observations["addresses"] = addresses
context.emit(target.url, self.name, "info", f"Resolved addresses: {', '.join(addresses)}")
if target.scheme == "https":
context.emit(target.url, self.name, "progress", f"Negotiating TLS on port {target.port}")
tls_context = ssl.create_default_context()
with socket.create_connection((target.host, target.port), timeout=context.timeout) as raw:
with tls_context.wrap_socket(raw, server_hostname=target.host) as wrapped:
cert = wrapped.getpeercert()
result.observations["tls"] = {
"subject": cert.get("subject"),
"issuer": cert.get("issuer"),
"not_before": cert.get("notBefore"),
"not_after": cert.get("notAfter"),
"san": [value for key, value in cert.get("subjectAltName", []) if key == "DNS"],
}
context.emit(
target.url,
self.name,
"info",
f"TLS certificate expires {cert.get('notAfter', 'unknown')}",
)
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
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import random
import string
import time
from pathlib import Path
from urllib.parse import urljoin
from redflare.core.models import Finding, ModuleResult, Target
from .base import Module, ModuleContext
from .http import request
DEFAULT_PATHS = [
"admin", "login", "dashboard", "api", "api/health", "graphql",
"swagger", "openapi.json", ".env", "config.json", "metrics", "actuator/health",
]
SENSITIVE = {"admin", "dashboard", ".env", "config.json", "metrics", "graphql", "openapi.json"}
class PathDiscoveryModule(Module):
name = "path_discovery"
description = "Rate-limited path discovery with wildcard response filtering"
def run(self, target: Target, context: ModuleContext) -> ModuleResult:
started = time.monotonic()
result = ModuleResult(self.name, target.url)
paths = load_paths(context.wordlist)[: context.max_paths]
interval = 1 / context.rate if context.rate > 0 else 0
try:
context.emit(target.url, self.name, "progress", f"Loading {len(paths)} paths; rate={context.rate:g}/s")
random_path = "redflare-" + "".join(random.choice(string.ascii_lowercase) for _ in range(18))
baseline = request(urljoin(target.url.rstrip("/") + "/", random_path), context.timeout)
baseline_signature = (baseline.status, len(baseline.body))
context.emit(target.url, self.name, "info", f"Wildcard baseline: status={baseline.status} bytes={len(baseline.body)}")
hits = []
progress_every = max(1, min(25, len(paths) // 10 or 1))
for index, path in enumerate(paths, start=1):
if index == 1 or index % progress_every == 0 or index == len(paths):
context.emit(target.url, self.name, "progress", f"Probing {index}/{len(paths)}: /{path}")
if interval:
time.sleep(interval)
url = urljoin(target.url.rstrip("/") + "/", path.lstrip("/"))
try:
response = request(url, context.timeout, max_body=250_000)
except Exception as exc:
result.errors.append(f"{url}: {type(exc).__name__}: {exc}")
continue
signature = (response.status, len(response.body))
if signature == baseline_signature or response.status == 404:
continue
hit = {"path": path, "url": url, "status": response.status, "bytes": len(response.body)}
hits.append(hit)
context.surface_graph.add_endpoint(
target.url,
url,
method="GET",
source="path-discovery",
content_type=response.headers.get("content-type"),
status=response.status,
)
context.emit(target.url, self.name, "info", f"Response HTTP {response.status}: /{path} ({len(response.body)} bytes)")
if response.status < 400:
severity = "medium" if path in SENSITIVE else "info"
result.findings.append(
Finding(
context.run_id,
target.url,
self.name,
"discovered-path",
f"Accessible path discovered: /{path}",
severity,
0.8,
f"The path returned HTTP {response.status} and differed from the wildcard baseline.",
hit,
["discovery", "web"],
)
)
result.observations.update({"paths_tested": len(paths), "wildcard_signature": baseline_signature, "hits": hits})
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 load_paths(wordlist: str | None) -> list[str]:
if not wordlist:
return list(DEFAULT_PATHS)
lines = Path(wordlist).read_text(encoding="utf-8").splitlines()
seen = set()
paths = []
for line in lines:
value = line.strip().lstrip("/")
if value and not value.startswith("#") and value not in seen:
seen.add(value)
paths.append(value)
return paths