2044 lines
47 KiB
Python
2044 lines
47 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
Server-side search for WiFi GPS Mapper reports.
|
||
|
||
Search criteria are intentionally represented as a dictionary so that
|
||
new search blocks can be added without redesigning the search layer.
|
||
"""
|
||
|
||
import json
|
||
from html import escape
|
||
|
||
from reports.utils import (
|
||
format_datetime as format_report_datetime,
|
||
)
|
||
|
||
|
||
MAP_STYLE_URL = (
|
||
"http://127.0.0.1:8080/styles/server-map/style.json"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Search criteria
|
||
# ============================================================
|
||
|
||
def normalize_criteria(
|
||
vendor=None,
|
||
):
|
||
criteria = {}
|
||
|
||
if vendor is not None:
|
||
vendor = str(vendor).strip()
|
||
|
||
if vendor:
|
||
criteria["vendor"] = vendor
|
||
|
||
return criteria
|
||
|
||
|
||
def build_where_clause(criteria):
|
||
conditions = []
|
||
parameters = []
|
||
|
||
vendor = criteria.get("vendor")
|
||
|
||
if vendor:
|
||
conditions.append(
|
||
"""
|
||
LOWER(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
COALESCE(ap.vendor, ''),
|
||
'-',
|
||
''
|
||
),
|
||
' ',
|
||
''
|
||
),
|
||
'_',
|
||
''
|
||
),
|
||
'.',
|
||
''
|
||
),
|
||
'/',
|
||
''
|
||
)
|
||
) LIKE
|
||
'%' ||
|
||
LOWER(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
REPLACE(
|
||
?,
|
||
'-',
|
||
''
|
||
),
|
||
' ',
|
||
''
|
||
),
|
||
'_',
|
||
''
|
||
),
|
||
'.',
|
||
''
|
||
),
|
||
'/',
|
||
''
|
||
)
|
||
) ||
|
||
'%'
|
||
"""
|
||
)
|
||
|
||
parameters.append(
|
||
vendor
|
||
)
|
||
|
||
if not conditions:
|
||
raise ValueError(
|
||
"At least one search criterion is required."
|
||
)
|
||
|
||
return (
|
||
" AND ".join(conditions),
|
||
parameters
|
||
)
|
||
|
||
# ============================================================
|
||
# Popular vendors
|
||
# ============================================================
|
||
|
||
def get_popular_vendors(
|
||
conn,
|
||
limit=20,
|
||
):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT
|
||
vendor,
|
||
COUNT(*) AS count
|
||
FROM access_points
|
||
WHERE
|
||
vendor IS NOT NULL
|
||
AND TRIM(vendor) != ''
|
||
GROUP BY vendor
|
||
ORDER BY
|
||
count DESC,
|
||
vendor COLLATE NOCASE ASC
|
||
LIMIT ?
|
||
""",
|
||
(
|
||
limit,
|
||
)
|
||
).fetchall()
|
||
|
||
return [
|
||
{
|
||
"vendor": row["vendor"],
|
||
"count": row["count"],
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
def get_matching_vendors(
|
||
conn,
|
||
criteria,
|
||
limit=20,
|
||
):
|
||
where_clause, parameters = (
|
||
build_where_clause(criteria)
|
||
)
|
||
|
||
rows = conn.execute(
|
||
f"""
|
||
SELECT
|
||
ap.vendor,
|
||
COUNT(*) AS count
|
||
FROM access_points AS ap
|
||
WHERE
|
||
{where_clause}
|
||
AND ap.vendor IS NOT NULL
|
||
AND TRIM(ap.vendor) != ''
|
||
GROUP BY
|
||
ap.vendor
|
||
ORDER BY
|
||
count DESC,
|
||
ap.vendor COLLATE NOCASE ASC
|
||
LIMIT ?
|
||
""",
|
||
parameters + [limit]
|
||
).fetchall()
|
||
|
||
return [
|
||
{
|
||
"vendor": row["vendor"],
|
||
"count": row["count"],
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
# ============================================================
|
||
# Access point search
|
||
# ============================================================
|
||
|
||
def search_access_points(
|
||
conn,
|
||
criteria,
|
||
):
|
||
where_clause, parameters = (
|
||
build_where_clause(criteria)
|
||
)
|
||
|
||
return conn.execute(
|
||
f"""
|
||
SELECT
|
||
ap.id,
|
||
ap.bssid,
|
||
ap.essid,
|
||
ap.encryption,
|
||
ap.cipher,
|
||
ap.akm,
|
||
ap.country,
|
||
ap.channel,
|
||
ap.frequency,
|
||
ap.vendor,
|
||
ap.first_seen,
|
||
ap.last_seen,
|
||
ap.times_seen,
|
||
ap.last_rssi,
|
||
ap.last_latitude,
|
||
ap.last_longitude,
|
||
ap.last_speed,
|
||
ap.has_handshake,
|
||
ap.has_pmkid,
|
||
ap.is_cracked
|
||
FROM access_points AS ap
|
||
WHERE
|
||
{where_clause}
|
||
ORDER BY
|
||
ap.vendor COLLATE NOCASE,
|
||
ap.essid COLLATE NOCASE,
|
||
ap.bssid
|
||
""",
|
||
parameters
|
||
).fetchall()
|
||
|
||
|
||
# ============================================================
|
||
# Credentials
|
||
# ============================================================
|
||
|
||
def load_credentials(
|
||
conn,
|
||
criteria,
|
||
):
|
||
where_clause, parameters = (
|
||
build_where_clause(criteria)
|
||
)
|
||
|
||
rows = conn.execute(
|
||
f"""
|
||
SELECT
|
||
c.access_point_id,
|
||
c.password,
|
||
c.source,
|
||
c.created_at,
|
||
c.verified
|
||
FROM credentials AS c
|
||
INNER JOIN access_points AS ap
|
||
ON ap.id = c.access_point_id
|
||
WHERE
|
||
{where_clause}
|
||
ORDER BY
|
||
c.access_point_id,
|
||
c.password
|
||
""",
|
||
parameters
|
||
).fetchall()
|
||
|
||
result = {}
|
||
|
||
for row in rows:
|
||
|
||
access_point_id = row["access_point_id"]
|
||
|
||
result.setdefault(
|
||
access_point_id,
|
||
[]
|
||
).append(
|
||
{
|
||
"password": row["password"],
|
||
"source": row["source"],
|
||
"created_at": row["created_at"],
|
||
"verified": bool(
|
||
row["verified"]
|
||
),
|
||
}
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
# ============================================================
|
||
# Handshakes / PMKIDs
|
||
# ============================================================
|
||
|
||
def load_handshakes(
|
||
conn,
|
||
criteria,
|
||
):
|
||
where_clause, parameters = (
|
||
build_where_clause(criteria)
|
||
)
|
||
|
||
rows = conn.execute(
|
||
f"""
|
||
SELECT
|
||
h.id,
|
||
h.access_point_id,
|
||
h.type,
|
||
h.hash22000,
|
||
h.message_pair,
|
||
h.captured_at
|
||
FROM handshakes AS h
|
||
INNER JOIN access_points AS ap
|
||
ON ap.id = h.access_point_id
|
||
WHERE
|
||
{where_clause}
|
||
ORDER BY
|
||
h.access_point_id,
|
||
h.captured_at
|
||
""",
|
||
parameters
|
||
).fetchall()
|
||
|
||
result = {}
|
||
|
||
for row in rows:
|
||
|
||
access_point_id = row["access_point_id"]
|
||
|
||
result.setdefault(
|
||
access_point_id,
|
||
[]
|
||
).append(
|
||
{
|
||
"id": row["id"],
|
||
"type": row["type"],
|
||
"hash22000": row["hash22000"],
|
||
"message_pair": row["message_pair"],
|
||
"captured_at": row["captured_at"],
|
||
}
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
# ============================================================
|
||
# Category
|
||
# ============================================================
|
||
|
||
def get_category(
|
||
access_point,
|
||
):
|
||
if access_point["is_cracked"]:
|
||
return "password"
|
||
|
||
if (
|
||
access_point["has_handshake"]
|
||
or
|
||
access_point["has_pmkid"]
|
||
):
|
||
return "handshake"
|
||
|
||
return "other"
|
||
|
||
|
||
# ============================================================
|
||
# Popup helpers
|
||
# ============================================================
|
||
|
||
def safe(value):
|
||
if value is None:
|
||
return "—"
|
||
|
||
text = str(value)
|
||
|
||
if not text:
|
||
return "—"
|
||
|
||
return escape(
|
||
text,
|
||
quote=True
|
||
)
|
||
|
||
|
||
def format_security(
|
||
access_point,
|
||
):
|
||
parts = []
|
||
|
||
if access_point["encryption"]:
|
||
parts.append(
|
||
access_point["encryption"]
|
||
)
|
||
|
||
if access_point["cipher"]:
|
||
parts.append(
|
||
access_point["cipher"]
|
||
)
|
||
|
||
if access_point["akm"]:
|
||
parts.append(
|
||
access_point["akm"]
|
||
)
|
||
|
||
if not parts:
|
||
return "—"
|
||
|
||
return escape(
|
||
" / ".join(
|
||
str(item)
|
||
for item in parts
|
||
),
|
||
quote=True
|
||
)
|
||
|
||
|
||
def popup_password(
|
||
access_point,
|
||
credentials,
|
||
):
|
||
password_items = []
|
||
|
||
for credential in credentials:
|
||
|
||
verified = (
|
||
"Yes"
|
||
if credential["verified"]
|
||
else
|
||
"No"
|
||
)
|
||
|
||
password_items.append(
|
||
"""
|
||
<div class="search-popup-password-item">
|
||
<div class="search-popup-password">
|
||
{password}
|
||
</div>
|
||
|
||
<div class="search-popup-meta">
|
||
Verified: {verified}
|
||
·
|
||
Source: {source}
|
||
</div>
|
||
</div>
|
||
""".format(
|
||
password=safe(
|
||
credential["password"]
|
||
),
|
||
verified=verified,
|
||
source=safe(
|
||
credential["source"]
|
||
)
|
||
)
|
||
)
|
||
|
||
if not password_items:
|
||
password_items.append(
|
||
"""
|
||
<div class="search-popup-muted">
|
||
No password details available.
|
||
</div>
|
||
"""
|
||
)
|
||
|
||
return """
|
||
<div class="search-popup">
|
||
<div class="search-popup-title">
|
||
{essid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>BSSID:</strong>
|
||
{bssid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Vendor:</strong>
|
||
{vendor}
|
||
</div>
|
||
|
||
<div class="search-popup-category password">
|
||
PASSWORD
|
||
</div>
|
||
|
||
<div class="search-popup-section">
|
||
{passwords}
|
||
</div>
|
||
|
||
<div class="search-popup-section">
|
||
<strong>Security:</strong>
|
||
{security}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Channel:</strong>
|
||
{channel}
|
||
·
|
||
<strong>Frequency:</strong>
|
||
{frequency}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>RSSI:</strong>
|
||
{rssi}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>First seen:</strong>
|
||
{first_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Last seen:</strong>
|
||
{last_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Times seen:</strong>
|
||
{times_seen}
|
||
</div>
|
||
</div>
|
||
""".format(
|
||
essid=safe(
|
||
access_point["essid"]
|
||
),
|
||
bssid=safe(
|
||
access_point["bssid"]
|
||
),
|
||
vendor=safe(
|
||
access_point["vendor"]
|
||
),
|
||
passwords="".join(
|
||
password_items
|
||
),
|
||
security=format_security(
|
||
access_point
|
||
),
|
||
channel=safe(
|
||
access_point["channel"]
|
||
),
|
||
frequency=safe(
|
||
access_point["frequency"]
|
||
),
|
||
rssi=safe(
|
||
access_point["last_rssi"]
|
||
),
|
||
first_seen=format_report_datetime(
|
||
access_point["first_seen"]
|
||
),
|
||
last_seen=format_report_datetime(
|
||
access_point["last_seen"]
|
||
),
|
||
times_seen=safe(
|
||
access_point["times_seen"]
|
||
)
|
||
)
|
||
|
||
|
||
def popup_handshake(
|
||
access_point,
|
||
handshakes,
|
||
):
|
||
eapol_count = 0
|
||
pmkid_count = 0
|
||
capture_times = []
|
||
|
||
for handshake in handshakes:
|
||
|
||
handshake_type = (
|
||
str(
|
||
handshake["type"]
|
||
or ""
|
||
).upper()
|
||
)
|
||
|
||
if "PMKID" in handshake_type:
|
||
pmkid_count += 1
|
||
else:
|
||
eapol_count += 1
|
||
|
||
if handshake["captured_at"]:
|
||
capture_times.append(
|
||
format_report_datetime(
|
||
handshake["captured_at"]
|
||
)
|
||
)
|
||
|
||
summary = []
|
||
|
||
if eapol_count:
|
||
summary.append(
|
||
f"EAPOL × {eapol_count}"
|
||
)
|
||
|
||
if pmkid_count:
|
||
summary.append(
|
||
f"PMKID × {pmkid_count}"
|
||
)
|
||
|
||
if not summary:
|
||
summary.append(
|
||
"Handshake / PMKID"
|
||
)
|
||
|
||
capture_html = ""
|
||
|
||
if capture_times:
|
||
|
||
capture_html = """
|
||
<div class="search-popup-section">
|
||
<strong>Capture time:</strong>
|
||
<div class="search-popup-list">
|
||
{times}
|
||
</div>
|
||
</div>
|
||
""".format(
|
||
times="".join(
|
||
"<div>{}</div>".format(
|
||
safe(timestamp)
|
||
)
|
||
for timestamp in capture_times
|
||
)
|
||
)
|
||
|
||
return """
|
||
<div class="search-popup">
|
||
<div class="search-popup-title">
|
||
{essid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>BSSID:</strong>
|
||
{bssid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Vendor:</strong>
|
||
{vendor}
|
||
</div>
|
||
|
||
<div class="search-popup-category handshake">
|
||
HANDSHAKE / PMKID
|
||
</div>
|
||
|
||
<div class="search-popup-section">
|
||
<strong>Material:</strong>
|
||
{summary}
|
||
</div>
|
||
|
||
{capture_html}
|
||
|
||
<div class="search-popup-section">
|
||
<strong>Security:</strong>
|
||
{security}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Channel:</strong>
|
||
{channel}
|
||
·
|
||
<strong>Frequency:</strong>
|
||
{frequency}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>RSSI:</strong>
|
||
{rssi}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>First seen:</strong>
|
||
{first_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Last seen:</strong>
|
||
{last_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Times seen:</strong>
|
||
{times_seen}
|
||
</div>
|
||
</div>
|
||
""".format(
|
||
essid=safe(
|
||
access_point["essid"]
|
||
),
|
||
bssid=safe(
|
||
access_point["bssid"]
|
||
),
|
||
vendor=safe(
|
||
access_point["vendor"]
|
||
),
|
||
summary=escape(
|
||
" · ".join(summary)
|
||
),
|
||
capture_html=capture_html,
|
||
security=format_security(
|
||
access_point
|
||
),
|
||
channel=safe(
|
||
access_point["channel"]
|
||
),
|
||
frequency=safe(
|
||
access_point["frequency"]
|
||
),
|
||
rssi=safe(
|
||
access_point["last_rssi"]
|
||
),
|
||
first_seen=format_report_datetime(
|
||
access_point["first_seen"]
|
||
),
|
||
last_seen=format_report_datetime(
|
||
access_point["last_seen"]
|
||
),
|
||
times_seen=safe(
|
||
access_point["times_seen"]
|
||
)
|
||
)
|
||
|
||
|
||
def popup_other(
|
||
access_point,
|
||
):
|
||
return """
|
||
<div class="search-popup">
|
||
<div class="search-popup-title">
|
||
{essid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>BSSID:</strong>
|
||
{bssid}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Vendor:</strong>
|
||
{vendor}
|
||
</div>
|
||
|
||
<div class="search-popup-category other">
|
||
OTHER AP
|
||
</div>
|
||
|
||
<div class="search-popup-section">
|
||
<strong>Security:</strong>
|
||
{security}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Channel:</strong>
|
||
{channel}
|
||
·
|
||
<strong>Frequency:</strong>
|
||
{frequency}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>RSSI:</strong>
|
||
{rssi}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>First seen:</strong>
|
||
{first_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Last seen:</strong>
|
||
{last_seen}
|
||
</div>
|
||
|
||
<div class="search-popup-line">
|
||
<strong>Times seen:</strong>
|
||
{times_seen}
|
||
</div>
|
||
</div>
|
||
""".format(
|
||
essid=safe(
|
||
access_point["essid"]
|
||
),
|
||
bssid=safe(
|
||
access_point["bssid"]
|
||
),
|
||
vendor=safe(
|
||
access_point["vendor"]
|
||
),
|
||
security=format_security(
|
||
access_point
|
||
),
|
||
channel=safe(
|
||
access_point["channel"]
|
||
),
|
||
frequency=safe(
|
||
access_point["frequency"]
|
||
),
|
||
rssi=safe(
|
||
access_point["last_rssi"]
|
||
),
|
||
first_seen=format_report_datetime(
|
||
access_point["first_seen"]
|
||
),
|
||
last_seen=format_report_datetime(
|
||
access_point["last_seen"]
|
||
),
|
||
times_seen=safe(
|
||
access_point["times_seen"]
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# GeoJSON
|
||
# ============================================================
|
||
|
||
def build_geojson(
|
||
access_points,
|
||
credentials,
|
||
handshakes,
|
||
):
|
||
features = []
|
||
|
||
for access_point in access_points:
|
||
|
||
latitude = access_point[
|
||
"last_latitude"
|
||
]
|
||
|
||
longitude = access_point[
|
||
"last_longitude"
|
||
]
|
||
|
||
if latitude is None or longitude is None:
|
||
continue
|
||
|
||
if (
|
||
latitude == 0
|
||
and
|
||
longitude == 0
|
||
):
|
||
continue
|
||
|
||
access_point_id = (
|
||
access_point["id"]
|
||
)
|
||
|
||
category = get_category(
|
||
access_point
|
||
)
|
||
|
||
if category == "password":
|
||
|
||
popup = popup_password(
|
||
access_point,
|
||
credentials.get(
|
||
access_point_id,
|
||
[]
|
||
)
|
||
)
|
||
|
||
elif category == "handshake":
|
||
|
||
popup = popup_handshake(
|
||
access_point,
|
||
handshakes.get(
|
||
access_point_id,
|
||
[]
|
||
)
|
||
)
|
||
|
||
else:
|
||
|
||
popup = popup_other(
|
||
access_point
|
||
)
|
||
|
||
features.append(
|
||
{
|
||
"type": "Feature",
|
||
"geometry": {
|
||
"type": "Point",
|
||
"coordinates": [
|
||
float(longitude),
|
||
float(latitude)
|
||
]
|
||
},
|
||
"properties": {
|
||
"id": access_point_id,
|
||
"category": category,
|
||
"popup": popup,
|
||
}
|
||
}
|
||
)
|
||
|
||
return {
|
||
"type": "FeatureCollection",
|
||
"features": features
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Hash export
|
||
# ============================================================
|
||
|
||
def get_hashes(
|
||
conn,
|
||
criteria,
|
||
):
|
||
where_clause, parameters = (
|
||
build_where_clause(criteria)
|
||
)
|
||
|
||
rows = conn.execute(
|
||
f"""
|
||
SELECT DISTINCT
|
||
h.hash22000
|
||
FROM handshakes AS h
|
||
INNER JOIN access_points AS ap
|
||
ON ap.id = h.access_point_id
|
||
WHERE
|
||
{where_clause}
|
||
AND h.hash22000 IS NOT NULL
|
||
AND TRIM(h.hash22000) != ''
|
||
ORDER BY
|
||
h.hash22000
|
||
""",
|
||
parameters
|
||
).fetchall()
|
||
|
||
return [
|
||
row["hash22000"]
|
||
for row in rows
|
||
]
|
||
|
||
|
||
def build_hash_export(
|
||
conn,
|
||
criteria,
|
||
):
|
||
hashes = get_hashes(
|
||
conn,
|
||
criteria
|
||
)
|
||
|
||
if not hashes:
|
||
return ""
|
||
|
||
return (
|
||
"\n".join(
|
||
str(item)
|
||
for item in hashes
|
||
)
|
||
+
|
||
"\n"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Search data
|
||
# ============================================================
|
||
|
||
def build_search_data(
|
||
conn,
|
||
criteria,
|
||
):
|
||
access_points = (
|
||
search_access_points(
|
||
conn,
|
||
criteria
|
||
)
|
||
)
|
||
|
||
credentials = (
|
||
load_credentials(
|
||
conn,
|
||
criteria
|
||
)
|
||
)
|
||
|
||
handshakes = (
|
||
load_handshakes(
|
||
conn,
|
||
criteria
|
||
)
|
||
)
|
||
|
||
geojson = build_geojson(
|
||
access_points,
|
||
credentials,
|
||
handshakes
|
||
)
|
||
|
||
hashes = get_hashes(
|
||
conn,
|
||
criteria
|
||
)
|
||
|
||
password_access_points = sum(
|
||
1
|
||
for access_point in access_points
|
||
if access_point["is_cracked"]
|
||
)
|
||
|
||
handshake_access_points = sum(
|
||
1
|
||
for access_point in access_points
|
||
if (
|
||
access_point["has_handshake"]
|
||
or
|
||
access_point["has_pmkid"]
|
||
)
|
||
)
|
||
|
||
matching_vendors = (
|
||
get_matching_vendors(
|
||
conn,
|
||
criteria
|
||
)
|
||
)
|
||
|
||
return {
|
||
"criteria": criteria,
|
||
"matched_access_points": len(
|
||
access_points
|
||
),
|
||
"mapped_access_points": len(
|
||
geojson["features"]
|
||
),
|
||
"password_access_points":
|
||
password_access_points,
|
||
"handshake_access_points":
|
||
handshake_access_points,
|
||
"hash_count": len(hashes),
|
||
"matching_vendors":
|
||
matching_vendors,
|
||
"geojson": geojson,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Search page
|
||
# ============================================================
|
||
|
||
def render_search_page(
|
||
conn,
|
||
context=None,
|
||
):
|
||
from reports.template import (
|
||
page_begin,
|
||
page_end,
|
||
)
|
||
|
||
if context is None:
|
||
from reports.template import (
|
||
ReportContext,
|
||
)
|
||
|
||
context = ReportContext(
|
||
mode="server"
|
||
)
|
||
|
||
maplibre_css_url = context.asset_url(
|
||
"maplibre/maplibre-gl.css"
|
||
)
|
||
|
||
popular_vendors = (
|
||
get_popular_vendors(conn)
|
||
)
|
||
|
||
vendor_items = []
|
||
|
||
for item in popular_vendors:
|
||
|
||
vendor = escape(
|
||
str(
|
||
item["vendor"]
|
||
),
|
||
quote=True
|
||
)
|
||
|
||
count = int(
|
||
item["count"]
|
||
)
|
||
|
||
vendor_items.append(
|
||
"""
|
||
<div class="search-vendor-item">
|
||
<span class="search-vendor-name">
|
||
{vendor}
|
||
</span>
|
||
|
||
<span class="search-vendor-count">
|
||
{count}
|
||
</span>
|
||
</div>
|
||
""".format(
|
||
vendor=vendor,
|
||
count=count
|
||
)
|
||
)
|
||
|
||
if vendor_items:
|
||
|
||
suggestions_html = "".join(
|
||
vendor_items
|
||
)
|
||
|
||
else:
|
||
|
||
suggestions_html = """
|
||
<span class="search-popup-muted">
|
||
No vendors available.
|
||
</span>
|
||
"""
|
||
|
||
html = page_begin(
|
||
"Search",
|
||
"Search access points in the WiFi GPS Mapper database.",
|
||
"search",
|
||
context
|
||
)
|
||
|
||
html += """
|
||
<section class="card search-card">
|
||
|
||
<h2>
|
||
Search by Vendor
|
||
</h2>
|
||
|
||
<form
|
||
id="search-form"
|
||
class="search-form"
|
||
>
|
||
|
||
<label
|
||
for="search-vendor"
|
||
class="search-label"
|
||
>
|
||
Vendor
|
||
</label>
|
||
|
||
<div class="search-input-row">
|
||
|
||
<input
|
||
id="search-vendor"
|
||
name="vendor"
|
||
type="text"
|
||
class="search-input"
|
||
autocomplete="off"
|
||
placeholder="e.g. Intel Corporation"
|
||
>
|
||
|
||
<button
|
||
type="submit"
|
||
class="button"
|
||
>
|
||
Найти
|
||
</button>
|
||
|
||
</div>
|
||
|
||
</form>
|
||
|
||
<div class="search-suggestions">
|
||
|
||
<div class="search-suggestions-title">
|
||
Popular vendors
|
||
</div>
|
||
|
||
<div
|
||
id="search-vendor-suggestions"
|
||
class="search-vendor-suggestions"
|
||
>
|
||
{suggestions}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<section class="card search-card search-future">
|
||
|
||
<h2>
|
||
Search by Access Point
|
||
</h2>
|
||
|
||
<div class="search-future-content">
|
||
Reserved for future search criteria:
|
||
ESSID, BSSID and other access point attributes.
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<section
|
||
id="search-results"
|
||
class="card search-card search-results"
|
||
hidden
|
||
>
|
||
|
||
<div class="search-results-header">
|
||
|
||
<div>
|
||
|
||
<h2>
|
||
Search results
|
||
</h2>
|
||
|
||
<div
|
||
id="search-query"
|
||
class="search-query"
|
||
></div>
|
||
|
||
<div
|
||
id="search-status"
|
||
class="search-status"
|
||
></div>
|
||
|
||
</div>
|
||
|
||
<button
|
||
id="search-export"
|
||
type="button"
|
||
class="button"
|
||
disabled
|
||
>
|
||
Export hashes
|
||
</button>
|
||
|
||
</div>
|
||
|
||
<div
|
||
id="search-vendors"
|
||
class="search-matched-vendors"
|
||
hidden
|
||
></div>
|
||
|
||
<div
|
||
id="search-map-controls"
|
||
class="search-map-controls"
|
||
>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-category="password"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-password"
|
||
></span>
|
||
Password
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-category="handshake"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-handshake"
|
||
></span>
|
||
Handshake / PMKID
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-category="other"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-other"
|
||
></span>
|
||
Other APs
|
||
</label>
|
||
|
||
</div>
|
||
|
||
<div
|
||
id="search-map"
|
||
class="search-map"
|
||
></div>
|
||
|
||
<div
|
||
id="search-map-empty"
|
||
class="search-map-empty"
|
||
hidden
|
||
>
|
||
No matching access points with valid coordinates.
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<link
|
||
rel="stylesheet"
|
||
href="{maplibre_css}"
|
||
>
|
||
|
||
<script src="{maplibre_js}"></script>
|
||
|
||
<script>
|
||
(() => {
|
||
|
||
const form = document.getElementById(
|
||
"search-form"
|
||
);
|
||
|
||
const vendorInput = document.getElementById(
|
||
"search-vendor"
|
||
);
|
||
|
||
const results = document.getElementById(
|
||
"search-results"
|
||
);
|
||
|
||
const query = document.getElementById(
|
||
"search-query"
|
||
);
|
||
|
||
const status = document.getElementById(
|
||
"search-status"
|
||
);
|
||
|
||
const exportButton = document.getElementById(
|
||
"search-export"
|
||
);
|
||
|
||
const matchedVendors = document.getElementById(
|
||
"search-vendors"
|
||
);
|
||
|
||
const mapContainer = document.getElementById(
|
||
"search-map"
|
||
);
|
||
|
||
const mapEmpty = document.getElementById(
|
||
"search-map-empty"
|
||
);
|
||
|
||
const suggestions = document.querySelectorAll(
|
||
".search-vendor-suggestion"
|
||
);
|
||
|
||
let map = null;
|
||
let searchParams = null;
|
||
|
||
const layerNames = {
|
||
password:
|
||
"search-password",
|
||
handshake:
|
||
"search-handshake",
|
||
other:
|
||
"search-other"
|
||
};
|
||
|
||
|
||
function setStatus(text) {
|
||
|
||
status.textContent = text;
|
||
|
||
}
|
||
|
||
|
||
function updateLayerVisibility(
|
||
category,
|
||
visible
|
||
) {
|
||
|
||
if (!map) {
|
||
return;
|
||
}
|
||
|
||
const layerId =
|
||
layerNames[category];
|
||
|
||
if (!map.getLayer(layerId)) {
|
||
return;
|
||
}
|
||
|
||
map.setLayoutProperty(
|
||
layerId,
|
||
"visibility",
|
||
visible
|
||
? "visible"
|
||
: "none"
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function createMap(
|
||
geojson
|
||
) {
|
||
|
||
if (map) {
|
||
|
||
map.remove();
|
||
|
||
map = null;
|
||
|
||
}
|
||
|
||
if (
|
||
!geojson.features
|
||
||
|
||
geojson.features.length === 0
|
||
) {
|
||
|
||
mapContainer.hidden = true;
|
||
mapEmpty.hidden = false;
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
mapContainer.hidden = false;
|
||
mapEmpty.hidden = true;
|
||
|
||
map = new maplibregl.Map({
|
||
|
||
container:
|
||
"search-map",
|
||
|
||
style:
|
||
"{map_style}",
|
||
|
||
center: [
|
||
geojson.features[0]
|
||
.geometry
|
||
.coordinates[0],
|
||
|
||
geojson.features[0]
|
||
.geometry
|
||
.coordinates[1]
|
||
],
|
||
|
||
zoom: 12
|
||
|
||
});
|
||
|
||
map.addControl(
|
||
new maplibregl.NavigationControl({
|
||
showZoom: true,
|
||
showCompass: true
|
||
}),
|
||
"top-right"
|
||
);
|
||
|
||
map.on(
|
||
"load",
|
||
() => {
|
||
|
||
map.addSource(
|
||
"search-aps",
|
||
{
|
||
type: "geojson",
|
||
data: geojson
|
||
}
|
||
);
|
||
|
||
map.addLayer(
|
||
{
|
||
id:
|
||
"search-password",
|
||
|
||
type:
|
||
"circle",
|
||
|
||
source:
|
||
"search-aps",
|
||
|
||
filter: [
|
||
"==",
|
||
[
|
||
"get",
|
||
"category"
|
||
],
|
||
"password"
|
||
],
|
||
|
||
paint: {
|
||
"circle-radius": 7,
|
||
"circle-stroke-width": 2,
|
||
"circle-color":
|
||
"#45d483",
|
||
"circle-stroke-color":
|
||
"#0f141a"
|
||
}
|
||
}
|
||
);
|
||
|
||
map.addLayer(
|
||
{
|
||
id:
|
||
"search-handshake",
|
||
|
||
type:
|
||
"circle",
|
||
|
||
source:
|
||
"search-aps",
|
||
|
||
filter: [
|
||
"==",
|
||
[
|
||
"get",
|
||
"category"
|
||
],
|
||
"handshake"
|
||
],
|
||
|
||
paint: {
|
||
"circle-radius": 7,
|
||
"circle-stroke-width": 2,
|
||
"circle-color":
|
||
"#e5c07b",
|
||
"circle-stroke-color":
|
||
"#0f141a"
|
||
}
|
||
}
|
||
);
|
||
|
||
map.addLayer(
|
||
{
|
||
id:
|
||
"search-other",
|
||
|
||
type:
|
||
"circle",
|
||
|
||
source:
|
||
"search-aps",
|
||
|
||
filter: [
|
||
"==",
|
||
[
|
||
"get",
|
||
"category"
|
||
],
|
||
"other"
|
||
],
|
||
|
||
paint: {
|
||
"circle-radius": 6,
|
||
"circle-stroke-width": 1,
|
||
"circle-color":
|
||
"#69a7ff",
|
||
"circle-stroke-color":
|
||
"#0f141a"
|
||
}
|
||
}
|
||
);
|
||
|
||
document.querySelectorAll(
|
||
"#search-map-controls input"
|
||
).forEach(
|
||
(checkbox) => {
|
||
|
||
updateLayerVisibility(
|
||
checkbox.dataset.category,
|
||
checkbox.checked
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
[
|
||
"search-password",
|
||
"search-handshake",
|
||
"search-other"
|
||
].forEach(
|
||
(layerId) => {
|
||
|
||
let hoverPopup = null;
|
||
let hoverFeatureId = null;
|
||
|
||
map.on(
|
||
"mousemove",
|
||
layerId,
|
||
(event) => {
|
||
|
||
const feature =
|
||
event.features[0];
|
||
|
||
if (!feature) {
|
||
return;
|
||
}
|
||
|
||
map.getCanvas()
|
||
.style
|
||
.cursor = "pointer";
|
||
|
||
const featureId =
|
||
feature.properties.id;
|
||
|
||
if (
|
||
hoverFeatureId ===
|
||
featureId
|
||
) {
|
||
return;
|
||
}
|
||
|
||
hoverFeatureId =
|
||
featureId;
|
||
|
||
if (hoverPopup) {
|
||
hoverPopup.remove();
|
||
}
|
||
|
||
hoverPopup =
|
||
new maplibregl.Popup({
|
||
closeButton: false,
|
||
closeOnClick: false,
|
||
offset: 10
|
||
})
|
||
.setLngLat(
|
||
event.lngLat
|
||
)
|
||
.setHTML(
|
||
feature
|
||
.properties
|
||
.popup
|
||
)
|
||
.addTo(map);
|
||
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"mouseleave",
|
||
layerId,
|
||
() => {
|
||
|
||
map.getCanvas()
|
||
.style
|
||
.cursor = "";
|
||
|
||
hoverFeatureId = null;
|
||
|
||
if (hoverPopup) {
|
||
hoverPopup.remove();
|
||
hoverPopup = null;
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"click",
|
||
layerId,
|
||
(event) => {
|
||
|
||
const feature =
|
||
event.features[0];
|
||
|
||
if (!feature) {
|
||
return;
|
||
}
|
||
|
||
if (hoverPopup) {
|
||
hoverPopup.remove();
|
||
hoverPopup = null;
|
||
}
|
||
|
||
hoverFeatureId = null;
|
||
|
||
new maplibregl.Popup({
|
||
closeButton: true,
|
||
closeOnClick: true,
|
||
offset: 10
|
||
})
|
||
.setLngLat(
|
||
event.lngLat
|
||
)
|
||
.setHTML(
|
||
feature
|
||
.properties
|
||
.popup
|
||
)
|
||
.addTo(map);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const bounds =
|
||
new maplibregl.LngLatBounds();
|
||
|
||
geojson.features.forEach(
|
||
(feature) => {
|
||
|
||
bounds.extend(
|
||
feature
|
||
.geometry
|
||
.coordinates
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
if (
|
||
geojson.features.length === 1
|
||
) {
|
||
|
||
map.setCenter(
|
||
geojson.features[0]
|
||
.geometry
|
||
.coordinates
|
||
);
|
||
|
||
map.setZoom(15);
|
||
|
||
} else {
|
||
|
||
map.fitBounds(
|
||
bounds,
|
||
{
|
||
padding: 60,
|
||
maxZoom: 15
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
suggestions.forEach(
|
||
(button) => {
|
||
|
||
button.addEventListener(
|
||
"click",
|
||
() => {
|
||
|
||
vendorInput.value =
|
||
button.dataset.vendor;
|
||
|
||
vendorInput.focus();
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
document.querySelectorAll(
|
||
"#search-map-controls input"
|
||
).forEach(
|
||
(checkbox) => {
|
||
|
||
checkbox.addEventListener(
|
||
"change",
|
||
() => {
|
||
|
||
updateLayerVisibility(
|
||
checkbox.dataset.category,
|
||
checkbox.checked
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
exportButton.addEventListener(
|
||
"click",
|
||
() => {
|
||
|
||
if (!searchParams) {
|
||
return;
|
||
}
|
||
|
||
const url =
|
||
"/reports/search/export?"
|
||
+
|
||
searchParams.toString();
|
||
|
||
window.location.href = url;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
form.addEventListener(
|
||
"submit",
|
||
async (event) => {
|
||
|
||
event.preventDefault();
|
||
|
||
const vendor =
|
||
vendorInput.value.trim();
|
||
|
||
if (!vendor) {
|
||
|
||
setStatus(
|
||
"Enter a vendor name."
|
||
);
|
||
|
||
query.textContent = "";
|
||
|
||
matchedVendors.innerHTML = "";
|
||
matchedVendors.hidden = true;
|
||
|
||
results.hidden = false;
|
||
|
||
exportButton.disabled = true;
|
||
|
||
if (map) {
|
||
map.remove();
|
||
map = null;
|
||
}
|
||
|
||
mapContainer.hidden = true;
|
||
mapEmpty.hidden = true;
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
searchParams =
|
||
new URLSearchParams();
|
||
|
||
searchParams.set(
|
||
"vendor",
|
||
vendor
|
||
);
|
||
|
||
results.hidden = false;
|
||
|
||
setStatus(
|
||
"Searching..."
|
||
);
|
||
|
||
exportButton.disabled = true;
|
||
|
||
try {
|
||
|
||
const response =
|
||
await fetch(
|
||
"/reports/search/data?"
|
||
+
|
||
searchParams.toString()
|
||
);
|
||
|
||
const data =
|
||
await response.json();
|
||
|
||
if (!response.ok) {
|
||
|
||
throw new Error(
|
||
data.error
|
||
||
|
||
"Search failed."
|
||
);
|
||
|
||
}
|
||
|
||
query.textContent =
|
||
"Query: " +
|
||
vendor;
|
||
|
||
setStatus(
|
||
"Matched "
|
||
+
|
||
data.matched_access_points
|
||
+
|
||
" access point(s), "
|
||
+
|
||
data.mapped_access_points
|
||
+
|
||
" mapped. "
|
||
+
|
||
"Password: "
|
||
+
|
||
data.password_access_points
|
||
+
|
||
" AP(s). "
|
||
+
|
||
"Handshake / PMKID: "
|
||
+
|
||
data.handshake_access_points
|
||
+
|
||
" AP(s). "
|
||
+
|
||
"Unique hashes: "
|
||
+
|
||
data.hash_count
|
||
+
|
||
"."
|
||
);
|
||
|
||
matchedVendors.innerHTML = "";
|
||
|
||
if (
|
||
data.matching_vendors
|
||
&&
|
||
data.matching_vendors.length
|
||
) {
|
||
|
||
const title =
|
||
document.createElement(
|
||
"div"
|
||
);
|
||
|
||
title.className =
|
||
"search-matched-vendors-title";
|
||
|
||
title.textContent =
|
||
"Matched vendors";
|
||
|
||
matchedVendors.appendChild(
|
||
title
|
||
);
|
||
|
||
const list =
|
||
document.createElement(
|
||
"div"
|
||
);
|
||
|
||
list.className =
|
||
"search-matched-vendors-list";
|
||
|
||
data.matching_vendors.forEach(
|
||
(item) => {
|
||
|
||
const row =
|
||
document.createElement(
|
||
"div"
|
||
);
|
||
|
||
row.className =
|
||
"search-matched-vendor";
|
||
|
||
const name =
|
||
document.createElement(
|
||
"span"
|
||
);
|
||
|
||
name.textContent =
|
||
item.vendor;
|
||
|
||
const count =
|
||
document.createElement(
|
||
"span"
|
||
);
|
||
|
||
count.textContent =
|
||
item.count;
|
||
|
||
row.appendChild(
|
||
name
|
||
);
|
||
|
||
row.appendChild(
|
||
count
|
||
);
|
||
|
||
list.appendChild(
|
||
row
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
matchedVendors.appendChild(
|
||
list
|
||
);
|
||
|
||
matchedVendors.hidden =
|
||
false;
|
||
|
||
} else {
|
||
|
||
matchedVendors.innerHTML = "";
|
||
matchedVendors.hidden = true;
|
||
|
||
}
|
||
|
||
exportButton.disabled =
|
||
data.hash_count === 0;
|
||
|
||
createMap(
|
||
data.geojson
|
||
);
|
||
|
||
} catch (error) {
|
||
|
||
setStatus(
|
||
error.message
|
||
);
|
||
|
||
query.textContent = "";
|
||
|
||
matchedVendors.innerHTML = "";
|
||
matchedVendors.hidden = true;
|
||
|
||
exportButton.disabled =
|
||
true;
|
||
|
||
if (map) {
|
||
map.remove();
|
||
map = null;
|
||
}
|
||
|
||
mapContainer.hidden = true;
|
||
mapEmpty.hidden = true;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
})();
|
||
</script>
|
||
""".replace(
|
||
"{suggestions}",
|
||
suggestions_html
|
||
).replace(
|
||
"{maplibre_css}",
|
||
maplibre_css_url
|
||
).replace(
|
||
"{maplibre_js}",
|
||
context.asset_url(
|
||
"maplibre/maplibre-gl.js"
|
||
)
|
||
).replace(
|
||
"{map_style}",
|
||
MAP_STYLE_URL
|
||
)
|
||
|
||
html += page_end()
|
||
|
||
return html |