#!/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( """
{password}
Verified: {verified}  ·  Source: {source}
""".format( password=safe( credential["password"] ), verified=verified, source=safe( credential["source"] ) ) ) if not password_items: password_items.append( """
No password details available.
""" ) return """
{essid}
BSSID: {bssid}
Vendor: {vendor}
PASSWORD
{passwords}
Security: {security}
Channel: {channel}  ·  Frequency: {frequency}
RSSI: {rssi}
First seen: {first_seen}
Last seen: {last_seen}
Times seen: {times_seen}
""".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 = """
Capture time:
{times}
""".format( times="".join( "
{}
".format( safe(timestamp) ) for timestamp in capture_times ) ) return """
{essid}
BSSID: {bssid}
Vendor: {vendor}
HANDSHAKE / PMKID
Material: {summary}
{capture_html}
Security: {security}
Channel: {channel}  ·  Frequency: {frequency}
RSSI: {rssi}
First seen: {first_seen}
Last seen: {last_seen}
Times seen: {times_seen}
""".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 """
{essid}
BSSID: {bssid}
Vendor: {vendor}
OTHER AP
Security: {security}
Channel: {channel}  ·  Frequency: {frequency}
RSSI: {rssi}
First seen: {first_seen}
Last seen: {last_seen}
Times seen: {times_seen}
""".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( """
{vendor} {count}
""".format( vendor=vendor, count=count ) ) if vendor_items: suggestions_html = "".join( vendor_items ) else: suggestions_html = """ No vendors available. """ html = page_begin( "Search", "Search access points in the WiFi GPS Mapper database.", "search", context ) html += """

Search by Vendor

Popular vendors
{suggestions}

Search by Access Point

Reserved for future search criteria: ESSID, BSSID and other access point attributes.
""".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