printerxpl-forge 6.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nse/README.md +204 -0
- nse/__init__.py +6 -0
- nse/install_nse.py +412 -0
- nse/lib/printerxpl.lua +238 -0
- nse/scripts/cups-info.nse +74 -0
- nse/scripts/cups-queue-info.nse +43 -0
- nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
- nse/scripts/http-device-mac.nse +107 -0
- nse/scripts/http-hp-ilo-info.nse +121 -0
- nse/scripts/http-info-xerox-enum.nse +101 -0
- nse/scripts/http-vuln-cve2022-1026.nse +158 -0
- nse/scripts/lexmark-config.nse +89 -0
- nse/scripts/pjl-ready-message.nse +106 -0
- nse/scripts/printer-banner.nse +217 -0
- nse/scripts/printer-cups-rce.nse +189 -0
- nse/scripts/printer-cve-detect.nse +279 -0
- nse/scripts/printer-discover.nse +205 -0
- nse/scripts/printer-firmware-exposed.nse +219 -0
- nse/scripts/printer-hp-pjl.nse +192 -0
- nse/scripts/printer-http-ews.nse +293 -0
- nse/scripts/printer-ipp-info.nse +235 -0
- nse/scripts/printer-lexmark-ipp.nse +203 -0
- nse/scripts/printer-passback.nse +204 -0
- nse/scripts/printer-pjl-info.nse +146 -0
- nse/scripts/printer-printnightmare.nse +211 -0
- nse/scripts/printer-snmp-info.nse +176 -0
- nse/scripts/printer-vuln-check.nse +256 -0
- nse/scripts/snmp-device-mac.nse +93 -0
- nse/scripts/snmp-info.nse +146 -0
- nse/scripts/snmp-sysdescr.nse +70 -0
- printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
- printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
- printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
- printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
- printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
- printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
- src/assets/fonts/gunplay.pfa +1671 -0
- src/assets/fonts/kshandwrt.pfa +315 -0
- src/assets/fonts/laksoner.pfa +2402 -0
- src/assets/fonts/paintcans.pfa +9699 -0
- src/assets/fonts/stencilod.pfa +4076 -0
- src/assets/fonts/takecover.pfa +26138 -0
- src/assets/fonts/topsecret.pfa +6652 -0
- src/assets/fonts/whoa.pfa +773 -0
- src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
- src/assets/mibs/Printer-MIB +4389 -0
- src/assets/mibs/README.md +9 -0
- src/assets/mibs/SNMPv2-MIB +854 -0
- src/assets/overlays/hacker.eps +596 -0
- src/assets/overlays/smiley.eps +214 -0
- src/assets/overlays/smiley2.eps +240 -0
- src/core/attack_orchestrator.py +1025 -0
- src/core/capabilities.py +323 -0
- src/core/destructive_audit.py +430 -0
- src/core/discovery.py +488 -0
- src/core/osdetect.py +74 -0
- src/core/poly_runner.py +579 -0
- src/core/printer.py +1426 -0
- src/main.py +2134 -0
- src/modules/install_printer.py +318 -0
- src/modules/login_bruteforce.py +852 -0
- src/modules/pcl.py +506 -0
- src/modules/pjl.py +3575 -0
- src/modules/print_job.py +1290 -0
- src/modules/ps.py +1102 -0
- src/payloads/__init__.py +98 -0
- src/payloads/assets/overlays/notice.eps +9 -0
- src/protocols/__init__.py +19 -0
- src/protocols/firmware.py +738 -0
- src/protocols/ipp.py +216 -0
- src/protocols/ipp_attacks.py +609 -0
- src/protocols/lpd.py +141 -0
- src/protocols/network_map.py +1004 -0
- src/protocols/raw.py +173 -0
- src/protocols/smb.py +359 -0
- src/protocols/ssrf_pivot.py +427 -0
- src/protocols/storage.py +587 -0
- src/ui/__init__.py +6 -0
- src/ui/interactive.py +742 -0
- src/ui/spinner.py +112 -0
- src/ui/tables.py +132 -0
- src/utils/banner_grabber.py +852 -0
- src/utils/codebook.py +456 -0
- src/utils/config.py +522 -0
- src/utils/cve_loader.py +158 -0
- src/utils/default_creds.py +134 -0
- src/utils/discovery_online.py +1327 -0
- src/utils/exploit_manager.py +805 -0
- src/utils/fuzzer.py +220 -0
- src/utils/helper.py +732 -0
- src/utils/local_printers.py +307 -0
- src/utils/ml_engine.py +491 -0
- src/utils/operators.py +474 -0
- src/utils/ports.py +234 -0
- src/utils/vuln_scanner.py +823 -0
- src/utils/wordlist_loader.py +412 -0
- src/version.py +36 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
PrinterXPL-Forge — Online Discovery Module
|
|
5
|
+
========================================
|
|
6
|
+
Structured dork-based discovery of exposed printers via 5 search engines:
|
|
7
|
+
Shodan, Censys, FOFA, ZoomEye, Netlas.
|
|
8
|
+
|
|
9
|
+
Printer context is always implicit — user does not need to specify "printer".
|
|
10
|
+
At least one filter parameter is REQUIRED in all cases.
|
|
11
|
+
No engine will run a broad (unfiltered) search — ever.
|
|
12
|
+
"""
|
|
13
|
+
# Author : Andre Henrique (@mrhenrike)
|
|
14
|
+
# GitHub : https://github.com/mrhenrike
|
|
15
|
+
# LinkedIn : https://linkedin.com/in/mrhenrike
|
|
16
|
+
# X/Twitter : https://x.com/mrhenrike
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
30
|
+
|
|
31
|
+
_log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# ── Optional engine library imports ───────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import shodan as _shodan_lib
|
|
37
|
+
SHODAN_AVAILABLE = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
SHODAN_AVAILABLE = False
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from censys.search import CensysHosts
|
|
43
|
+
CENSYS_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
CENSYS_AVAILABLE = False
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import requests as _requests
|
|
49
|
+
REQUESTS_AVAILABLE = True
|
|
50
|
+
except ImportError:
|
|
51
|
+
REQUESTS_AVAILABLE = False
|
|
52
|
+
|
|
53
|
+
# FOFA — uses requests (no dedicated package required)
|
|
54
|
+
FOFA_AVAILABLE = REQUESTS_AVAILABLE
|
|
55
|
+
|
|
56
|
+
# ZoomEye — uses requests
|
|
57
|
+
ZOOMEYE_AVAILABLE = REQUESTS_AVAILABLE
|
|
58
|
+
|
|
59
|
+
# Netlas — official SDK or requests fallback
|
|
60
|
+
try:
|
|
61
|
+
import netlas as _netlas_sdk
|
|
62
|
+
NETLAS_SDK_AVAILABLE = True
|
|
63
|
+
except ImportError:
|
|
64
|
+
NETLAS_SDK_AVAILABLE = False
|
|
65
|
+
NETLAS_AVAILABLE = REQUESTS_AVAILABLE or NETLAS_SDK_AVAILABLE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Geographic region → ISO-3166-1 alpha-2 code map ─────────────────────────
|
|
69
|
+
|
|
70
|
+
_REGION_COUNTRIES: Dict[str, List[str]] = {
|
|
71
|
+
'latin_america': ['AR', 'BO', 'BR', 'CL', 'CO', 'CR', 'CU', 'DO', 'EC',
|
|
72
|
+
'GT', 'HN', 'MX', 'NI', 'PA', 'PE', 'PR', 'SV', 'UY', 'VE'],
|
|
73
|
+
'south_america': ['AR', 'BO', 'BR', 'CL', 'CO', 'EC', 'GY', 'PE', 'PY', 'SR', 'UY', 'VE'],
|
|
74
|
+
'central_america': ['CR', 'GT', 'HN', 'MX', 'NI', 'PA', 'SV', 'BZ'],
|
|
75
|
+
'north_america': ['US', 'CA', 'MX'],
|
|
76
|
+
'europe': ['GB', 'DE', 'FR', 'IT', 'ES', 'PT', 'NL', 'BE', 'CH',
|
|
77
|
+
'AT', 'SE', 'NO', 'DK', 'FI', 'PL', 'CZ', 'HU', 'RO',
|
|
78
|
+
'BG', 'HR', 'GR', 'IE', 'SK', 'SI', 'EE', 'LT', 'LV'],
|
|
79
|
+
'asia': ['CN', 'JP', 'KR', 'IN', 'SG', 'MY', 'TH', 'VN', 'ID',
|
|
80
|
+
'PH', 'TW', 'HK', 'BD', 'PK', 'LK', 'MM'],
|
|
81
|
+
'middle_east': ['SA', 'AE', 'IL', 'TR', 'EG', 'IR', 'IQ', 'JO', 'KW', 'LB', 'QA', 'OM', 'BH', 'YE'],
|
|
82
|
+
'africa': ['ZA', 'NG', 'EG', 'KE', 'MA', 'TN', 'GH', 'ET', 'CI', 'TZ', 'SN', 'UG', 'CM'],
|
|
83
|
+
'oceania': ['AU', 'NZ', 'FJ', 'PG', 'WS', 'SB'],
|
|
84
|
+
'north_africa': ['EG', 'MA', 'TN', 'LY', 'DZ', 'SD'],
|
|
85
|
+
'southeast_asia': ['SG', 'MY', 'TH', 'VN', 'ID', 'PH', 'MM', 'KH', 'LA', 'BN'],
|
|
86
|
+
'eastern_europe': ['PL', 'CZ', 'HU', 'RO', 'BG', 'SK', 'UA', 'BY', 'MD', 'RS', 'BA', 'AL', 'MK'],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Country name → ISO code (pt-BR and en-US)
|
|
90
|
+
_COUNTRY_NAME_TO_CODE: Dict[str, str] = {
|
|
91
|
+
# Portuguese / Spanish
|
|
92
|
+
'brasil': 'BR', 'brazil': 'BR',
|
|
93
|
+
'argentina': 'AR',
|
|
94
|
+
'mexico': 'MX', 'méxico': 'MX',
|
|
95
|
+
'colombia': 'CO',
|
|
96
|
+
'chile': 'CL',
|
|
97
|
+
'peru': 'PE', 'perú': 'PE',
|
|
98
|
+
'venezuela': 'VE',
|
|
99
|
+
'equador': 'EC', 'ecuador': 'EC',
|
|
100
|
+
'bolivia': 'BO',
|
|
101
|
+
'paraguai': 'PY', 'paraguay': 'PY',
|
|
102
|
+
'uruguai': 'UY', 'uruguay': 'UY',
|
|
103
|
+
'cuba': 'CU',
|
|
104
|
+
'costa rica': 'CR',
|
|
105
|
+
'panama': 'PA', 'panamá': 'PA',
|
|
106
|
+
'guatemala': 'GT',
|
|
107
|
+
'honduras': 'HN',
|
|
108
|
+
'el salvador': 'SV',
|
|
109
|
+
'nicaragua': 'NI',
|
|
110
|
+
'republica dominicana': 'DO', 'dominican republic': 'DO',
|
|
111
|
+
# English
|
|
112
|
+
'united states': 'US', 'usa': 'US', 'united states of america': 'US',
|
|
113
|
+
'estados unidos': 'US',
|
|
114
|
+
'canada': 'CA', 'canadá': 'CA',
|
|
115
|
+
'united kingdom': 'GB', 'uk': 'GB', 'england': 'GB',
|
|
116
|
+
'reino unido': 'GB',
|
|
117
|
+
'germany': 'DE', 'alemanha': 'DE', 'deutschland': 'DE',
|
|
118
|
+
'france': 'FR', 'franca': 'FR', 'frança': 'FR',
|
|
119
|
+
'italy': 'IT', 'italia': 'IT', 'itália': 'IT',
|
|
120
|
+
'spain': 'ES', 'espanha': 'ES', 'españa': 'ES',
|
|
121
|
+
'portugal': 'PT',
|
|
122
|
+
'netherlands': 'NL', 'holanda': 'NL', 'holland': 'NL',
|
|
123
|
+
'belgium': 'BE', 'bélgica': 'BE',
|
|
124
|
+
'switzerland': 'CH', 'suíça': 'CH',
|
|
125
|
+
'austria': 'AT', 'áustria': 'AT',
|
|
126
|
+
'sweden': 'SE', 'suécia': 'SE',
|
|
127
|
+
'norway': 'NO', 'noruega': 'NO',
|
|
128
|
+
'denmark': 'DK', 'dinamarca': 'DK',
|
|
129
|
+
'finland': 'FI', 'finlândia': 'FI',
|
|
130
|
+
'poland': 'PL', 'polônia': 'PL',
|
|
131
|
+
'russia': 'RU', 'rússia': 'RU',
|
|
132
|
+
'china': 'CN',
|
|
133
|
+
'japan': 'JP', 'japão': 'JP',
|
|
134
|
+
'south korea': 'KR', 'coreia do sul': 'KR',
|
|
135
|
+
'india': 'IN',
|
|
136
|
+
'australia': 'AU', 'austrália': 'AU',
|
|
137
|
+
'new zealand': 'NZ', 'nova zelândia': 'NZ',
|
|
138
|
+
'south africa': 'ZA', 'africa do sul': 'ZA',
|
|
139
|
+
'nigeria': 'NG',
|
|
140
|
+
'egypt': 'EG', 'egito': 'EG',
|
|
141
|
+
'turkey': 'TR', 'turquia': 'TR',
|
|
142
|
+
'israel': 'IL',
|
|
143
|
+
'saudi arabia': 'SA', 'arabia saudita': 'SA',
|
|
144
|
+
'united arab emirates': 'AE', 'emirates': 'AE', 'emirados arabes': 'AE',
|
|
145
|
+
'singapore': 'SG', 'singapura': 'SG',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Vendor name → canonical Shodan/Censys search strings
|
|
149
|
+
_VENDOR_SEARCH_TERMS: Dict[str, List[str]] = {
|
|
150
|
+
'hp': ['"HP LaserJet"', '"HP OfficeJet"', '"HP DeskJet"', '"HP Color LaserJet"', '"Hewlett-Packard"'],
|
|
151
|
+
'epson': ['"EPSON"', '"Epson"'],
|
|
152
|
+
'ricoh': ['"Ricoh"', '"Aficio"', '"RICOH"'],
|
|
153
|
+
'xerox': ['"Xerox"', '"Phaser"', '"WorkCentre"'],
|
|
154
|
+
'brother': ['"Brother"', '"BROTHER"'],
|
|
155
|
+
'canon': ['"Canon"', '"imageRUNNER"', '"CANON"'],
|
|
156
|
+
'kyocera': ['"Kyocera"', '"TASKalfa"', '"FS-"', '"KYOCERA"'],
|
|
157
|
+
'konica': ['"Konica Minolta"', '"bizhub"', '"KONICA MINOLTA"'],
|
|
158
|
+
'samsung': ['"Samsung"', '"SAMSUNG"'],
|
|
159
|
+
'lexmark': ['"Lexmark"', '"LEXMARK"'],
|
|
160
|
+
'sharp': ['"Sharp"', '"SHARP"', '"MX-"'],
|
|
161
|
+
'oki': ['"OKI"', '"OKIDATA"'],
|
|
162
|
+
'toshiba': ['"Toshiba"', '"TOSHIBA"', '"e-Studio"'],
|
|
163
|
+
'fujifilm': ['"Fujifilm"', '"Fuji Xerox"', '"FUJIFILM"'],
|
|
164
|
+
'zebra': ['"Zebra"', '"ZEBRA"'],
|
|
165
|
+
'pantum': ['"Pantum"', '"PANTUM"'],
|
|
166
|
+
'sindoh': ['"Sindoh"'],
|
|
167
|
+
'develop': ['"Develop"', '"ineo"'],
|
|
168
|
+
'utax': ['"UTAX"', '"Triumph-Adler"'],
|
|
169
|
+
'dell': ['"Dell"', '"DELL"'],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Implicit printer-related Shodan filter terms (always appended)
|
|
173
|
+
_SHODAN_PRINTER_BASE = '(port:9100 OR port:515 OR port:631)'
|
|
174
|
+
_CENSYS_PRINTER_BASE = '(services.port=9100 OR services.port=515 OR services.port=631)'
|
|
175
|
+
|
|
176
|
+
# Port → protocol label
|
|
177
|
+
_PORT_LABELS = {9100: 'RAW/PJL', 515: 'LPD', 631: 'IPP', 80: 'HTTP-EWS', 443: 'HTTPS-EWS'}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class DiscoveryParams:
|
|
182
|
+
"""Structured parameters for a targeted online printer search."""
|
|
183
|
+
vendors: List[str] = field(default_factory=list) # e.g. ['epson', 'ricoh']
|
|
184
|
+
model: Optional[str] = None # e.g. "deskjet pro 5500"
|
|
185
|
+
countries: List[str] = field(default_factory=list) # ISO codes e.g. ['BR', 'AR']
|
|
186
|
+
cities: List[str] = field(default_factory=list) # e.g. ['Sao Paulo', 'Belem'] — single country only
|
|
187
|
+
regions: List[str] = field(default_factory=list) # e.g. ['latin_america']
|
|
188
|
+
ports: List[int] = field(default_factory=list) # e.g. [515, 9100]
|
|
189
|
+
org: Optional[str] = None # e.g. "Telefonica"
|
|
190
|
+
cpe: Optional[str] = None # Censys CPE e.g. "cpe:/h:hp:laserjet"
|
|
191
|
+
limit: int = 100
|
|
192
|
+
|
|
193
|
+
# ── backwards-compat shim: accept legacy `city` kwarg ──────────────────
|
|
194
|
+
def __post_init__(self) -> None:
|
|
195
|
+
"""Normalise legacy ``city`` (str) → ``cities`` (list) if needed."""
|
|
196
|
+
# Nothing to do — kept as hook for future compat if needed.
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def has_filters(self) -> bool:
|
|
200
|
+
"""Returns True if at least one discovery filter was provided."""
|
|
201
|
+
return bool(
|
|
202
|
+
self.vendors or self.model or self.countries or
|
|
203
|
+
self.cities or self.regions or self.ports or
|
|
204
|
+
self.org or self.cpe
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def resolve_country_codes(self) -> List[str]:
|
|
208
|
+
"""Resolve names to ISO codes and expand regions."""
|
|
209
|
+
codes: Set[str] = set()
|
|
210
|
+
for c in self.countries:
|
|
211
|
+
normalized = c.strip().lower()
|
|
212
|
+
if len(c) == 2 and c.upper() == c:
|
|
213
|
+
codes.add(c.upper())
|
|
214
|
+
elif normalized in _COUNTRY_NAME_TO_CODE:
|
|
215
|
+
codes.add(_COUNTRY_NAME_TO_CODE[normalized])
|
|
216
|
+
else:
|
|
217
|
+
# Try partial match
|
|
218
|
+
for name, code in _COUNTRY_NAME_TO_CODE.items():
|
|
219
|
+
if normalized in name or name in normalized:
|
|
220
|
+
codes.add(code)
|
|
221
|
+
break
|
|
222
|
+
for region in self.regions:
|
|
223
|
+
region_key = region.lower().replace('-', '_').replace(' ', '_')
|
|
224
|
+
codes.update(_REGION_COUNTRIES.get(region_key, []))
|
|
225
|
+
return sorted(codes)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class DorkQueryBuilder:
|
|
229
|
+
"""
|
|
230
|
+
Builds structured Shodan and Censys dork queries from DiscoveryParams.
|
|
231
|
+
|
|
232
|
+
Printer context is always implicit — user only provides filtering criteria.
|
|
233
|
+
Supports multi-vendor, multi-country, region expansion, and CPE filtering.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, params: DiscoveryParams) -> None:
|
|
237
|
+
self.params = params
|
|
238
|
+
|
|
239
|
+
# ── Shodan ────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def build_shodan_queries(self) -> List[Dict[str, str]]:
|
|
242
|
+
"""
|
|
243
|
+
Returns a list of Shodan query dicts: {query, description}.
|
|
244
|
+
|
|
245
|
+
Each vendor generates its own query (API-efficient).
|
|
246
|
+
If no vendor is specified, uses generic printer banner terms.
|
|
247
|
+
Country/region/port/city are always appended as Shodan filters.
|
|
248
|
+
"""
|
|
249
|
+
geo_part = self._shodan_geo_part()
|
|
250
|
+
port_part = self._shodan_port_part()
|
|
251
|
+
city_part = self._shodan_city_part()
|
|
252
|
+
org_part = f' org:"{self.params.org}"' if self.params.org else ''
|
|
253
|
+
model_part = f' "{self.params.model}"' if self.params.model else ''
|
|
254
|
+
suffix = f"{geo_part}{city_part}{org_part}{model_part}{port_part}"
|
|
255
|
+
|
|
256
|
+
queries: List[Dict[str, str]] = []
|
|
257
|
+
|
|
258
|
+
if self.params.vendors:
|
|
259
|
+
for vendor in self.params.vendors:
|
|
260
|
+
vendor_key = vendor.lower()
|
|
261
|
+
search_terms = _VENDOR_SEARCH_TERMS.get(vendor_key, [f'"{vendor}"'])
|
|
262
|
+
# Use the first 2 search terms per vendor to avoid too many queries
|
|
263
|
+
for term in search_terms[:2]:
|
|
264
|
+
query = f"{term}{suffix}"
|
|
265
|
+
queries.append({
|
|
266
|
+
'query': query,
|
|
267
|
+
'description': f"{vendor.title()} printers{self._geo_label()}",
|
|
268
|
+
'vendor': vendor,
|
|
269
|
+
})
|
|
270
|
+
else:
|
|
271
|
+
# Generic: use printer banner signatures
|
|
272
|
+
generic_terms = [
|
|
273
|
+
'"@PJL INFO"',
|
|
274
|
+
'"@PJL USTATUS"',
|
|
275
|
+
'"READY" port:9100',
|
|
276
|
+
'"PJL"',
|
|
277
|
+
]
|
|
278
|
+
for term in generic_terms:
|
|
279
|
+
query = f"{term}{suffix}"
|
|
280
|
+
if not port_part:
|
|
281
|
+
query += f" {_SHODAN_PRINTER_BASE}"
|
|
282
|
+
queries.append({
|
|
283
|
+
'query': query.strip(),
|
|
284
|
+
'description': f"Generic printers{self._geo_label()}",
|
|
285
|
+
'vendor': None,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return queries
|
|
289
|
+
|
|
290
|
+
def _shodan_geo_part(self) -> str:
|
|
291
|
+
codes = self.params.resolve_country_codes()
|
|
292
|
+
if not codes:
|
|
293
|
+
return ''
|
|
294
|
+
if len(codes) == 1:
|
|
295
|
+
return f' country:{codes[0]}'
|
|
296
|
+
# Multiple countries → join with OR in Shodan
|
|
297
|
+
return ' (' + ' OR '.join(f'country:{c}' for c in codes) + ')'
|
|
298
|
+
|
|
299
|
+
def _shodan_port_part(self) -> str:
|
|
300
|
+
if not self.params.ports:
|
|
301
|
+
return ''
|
|
302
|
+
if len(self.params.ports) == 1:
|
|
303
|
+
return f' port:{self.params.ports[0]}'
|
|
304
|
+
return ' (' + ' OR '.join(f'port:{p}' for p in self.params.ports) + ')'
|
|
305
|
+
|
|
306
|
+
def _shodan_city_part(self) -> str:
|
|
307
|
+
if not self.params.cities:
|
|
308
|
+
return ''
|
|
309
|
+
if len(self.params.cities) == 1:
|
|
310
|
+
return f' city:"{self.params.cities[0]}"'
|
|
311
|
+
return ' (' + ' OR '.join(f'city:"{c}"' for c in self.params.cities) + ')'
|
|
312
|
+
|
|
313
|
+
# ── Censys ────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def build_censys_queries(self) -> List[Dict[str, str]]:
|
|
316
|
+
"""Returns a list of Censys query dicts: {query, description}."""
|
|
317
|
+
geo_part = self._censys_geo_part()
|
|
318
|
+
port_part = self._censys_port_part()
|
|
319
|
+
city_part = self._censys_city_part()
|
|
320
|
+
org_part = f' AND autonomous_system.name="{self.params.org}"' if self.params.org else ''
|
|
321
|
+
cpe_part = f' AND services.software.uniform_resource_identifier="{self.params.cpe}"' if self.params.cpe else ''
|
|
322
|
+
model_part = f' AND services.banner="{self.params.model}"' if self.params.model else ''
|
|
323
|
+
suffix = f"{geo_part}{city_part}{org_part}{cpe_part}{model_part}{port_part}"
|
|
324
|
+
|
|
325
|
+
queries: List[Dict[str, str]] = []
|
|
326
|
+
|
|
327
|
+
if self.params.vendors:
|
|
328
|
+
for vendor in self.params.vendors:
|
|
329
|
+
vendor_key = vendor.lower()
|
|
330
|
+
raw_terms = _VENDOR_SEARCH_TERMS.get(vendor_key, [f'"{vendor}"'])
|
|
331
|
+
# Strip Shodan quoting style → Censys banner search
|
|
332
|
+
censys_terms = [t.strip('"') for t in raw_terms[:2]]
|
|
333
|
+
for term in censys_terms:
|
|
334
|
+
query = f'services.banner="{term}"{suffix}'
|
|
335
|
+
if not port_part:
|
|
336
|
+
# Append implicit printer port filter
|
|
337
|
+
query += f' AND ({_CENSYS_PRINTER_BASE.lstrip("(")}'
|
|
338
|
+
queries.append({
|
|
339
|
+
'query': query,
|
|
340
|
+
'description': f"{vendor.title()} printers (Censys){self._geo_label()}",
|
|
341
|
+
'vendor': vendor,
|
|
342
|
+
})
|
|
343
|
+
else:
|
|
344
|
+
generic_censys = [
|
|
345
|
+
'services.banner="@PJL"',
|
|
346
|
+
'services.banner="READY"',
|
|
347
|
+
]
|
|
348
|
+
for term in generic_censys:
|
|
349
|
+
query = f"{term}{suffix}"
|
|
350
|
+
if not port_part:
|
|
351
|
+
query += f' AND {_CENSYS_PRINTER_BASE}'
|
|
352
|
+
queries.append({
|
|
353
|
+
'query': query,
|
|
354
|
+
'description': f"Generic printers (Censys){self._geo_label()}",
|
|
355
|
+
'vendor': None,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return queries
|
|
359
|
+
|
|
360
|
+
def _censys_geo_part(self) -> str:
|
|
361
|
+
codes = self.params.resolve_country_codes()
|
|
362
|
+
if not codes:
|
|
363
|
+
return ''
|
|
364
|
+
if len(codes) == 1:
|
|
365
|
+
return f' AND location.country_code="{codes[0]}"'
|
|
366
|
+
return ' AND (' + ' OR '.join(f'location.country_code="{c}"' for c in codes) + ')'
|
|
367
|
+
|
|
368
|
+
def _censys_port_part(self) -> str:
|
|
369
|
+
if not self.params.ports:
|
|
370
|
+
return ''
|
|
371
|
+
if len(self.params.ports) == 1:
|
|
372
|
+
return f' AND services.port={self.params.ports[0]}'
|
|
373
|
+
return ' AND (' + ' OR '.join(f'services.port={p}' for p in self.params.ports) + ')'
|
|
374
|
+
|
|
375
|
+
def _censys_city_part(self) -> str:
|
|
376
|
+
if not self.params.cities:
|
|
377
|
+
return ''
|
|
378
|
+
if len(self.params.cities) == 1:
|
|
379
|
+
return f' AND location.city="{self.params.cities[0]}"'
|
|
380
|
+
return ' AND (' + ' OR '.join(f'location.city="{c}"' for c in self.params.cities) + ')'
|
|
381
|
+
|
|
382
|
+
# ── FOFA ──────────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def build_fofa_queries(self) -> List[Dict[str, str]]:
|
|
385
|
+
"""
|
|
386
|
+
Builds FOFA query strings.
|
|
387
|
+
|
|
388
|
+
FOFA syntax: field="value" && field="value" || field="value"
|
|
389
|
+
Fields: port, country, region, city, org, banner, header, protocol, app, product
|
|
390
|
+
Query is base64-encoded before sending to the API.
|
|
391
|
+
Implicit printer filter: at minimum port="9100" or banner="@PJL".
|
|
392
|
+
"""
|
|
393
|
+
geo_part = self._fofa_geo_part()
|
|
394
|
+
port_part = self._fofa_port_part()
|
|
395
|
+
city_part = self._fofa_city_part()
|
|
396
|
+
org_part = f' && org="{self.params.org}"' if self.params.org else ''
|
|
397
|
+
model_part = f' && banner="{self.params.model}"' if self.params.model else ''
|
|
398
|
+
suffix = f"{geo_part}{city_part}{org_part}{model_part}{port_part}"
|
|
399
|
+
|
|
400
|
+
queries: List[Dict[str, str]] = []
|
|
401
|
+
|
|
402
|
+
if self.params.vendors:
|
|
403
|
+
for vendor in self.params.vendors:
|
|
404
|
+
vendor_key = vendor.lower()
|
|
405
|
+
raw_terms = _VENDOR_SEARCH_TERMS.get(vendor_key, [f'"{vendor}"'])
|
|
406
|
+
# FOFA uses banner field for string matching
|
|
407
|
+
fofa_terms = [t.strip('"') for t in raw_terms[:2]]
|
|
408
|
+
for term in fofa_terms:
|
|
409
|
+
q = f'banner="{term}"{suffix}'
|
|
410
|
+
if not port_part:
|
|
411
|
+
q += ' && (port="9100" || port="515" || port="631")'
|
|
412
|
+
queries.append({
|
|
413
|
+
'query': q,
|
|
414
|
+
'query_b64': base64.b64encode(q.encode()).decode(),
|
|
415
|
+
'description': f"{vendor.title()} printers (FOFA){self._geo_label()}",
|
|
416
|
+
'vendor': vendor,
|
|
417
|
+
})
|
|
418
|
+
else:
|
|
419
|
+
generic_terms = ['banner="@PJL"', 'banner="PJL READY"']
|
|
420
|
+
for term in generic_terms:
|
|
421
|
+
q = f'{term}{suffix}'
|
|
422
|
+
if not port_part:
|
|
423
|
+
q += ' && (port="9100" || port="515")'
|
|
424
|
+
queries.append({
|
|
425
|
+
'query': q,
|
|
426
|
+
'query_b64': base64.b64encode(q.encode()).decode(),
|
|
427
|
+
'description': f"Generic PJL printers (FOFA){self._geo_label()}",
|
|
428
|
+
'vendor': None,
|
|
429
|
+
})
|
|
430
|
+
return queries
|
|
431
|
+
|
|
432
|
+
def _fofa_geo_part(self) -> str:
|
|
433
|
+
codes = self.params.resolve_country_codes()
|
|
434
|
+
if not codes:
|
|
435
|
+
return ''
|
|
436
|
+
if len(codes) == 1:
|
|
437
|
+
return f' && country="{codes[0]}"'
|
|
438
|
+
return ' && (' + ' || '.join(f'country="{c}"' for c in codes) + ')'
|
|
439
|
+
|
|
440
|
+
def _fofa_port_part(self) -> str:
|
|
441
|
+
if not self.params.ports:
|
|
442
|
+
return ''
|
|
443
|
+
if len(self.params.ports) == 1:
|
|
444
|
+
return f' && port="{self.params.ports[0]}"'
|
|
445
|
+
return ' && (' + ' || '.join(f'port="{p}"' for p in self.params.ports) + ')'
|
|
446
|
+
|
|
447
|
+
def _fofa_city_part(self) -> str:
|
|
448
|
+
if not self.params.cities:
|
|
449
|
+
return ''
|
|
450
|
+
if len(self.params.cities) == 1:
|
|
451
|
+
return f' && city="{self.params.cities[0]}"'
|
|
452
|
+
return ' && (' + ' || '.join(f'city="{c}"' for c in self.params.cities) + ')'
|
|
453
|
+
|
|
454
|
+
# ── ZoomEye ───────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def build_zoomeye_queries(self) -> List[Dict[str, str]]:
|
|
457
|
+
"""
|
|
458
|
+
Builds ZoomEye query strings.
|
|
459
|
+
|
|
460
|
+
ZoomEye syntax: field:value +field:value (AND), -field:value (NOT)
|
|
461
|
+
Fields: port, country, city, hostname, asn, org, app, ver, os, ssl, banner
|
|
462
|
+
Note: country field uses full name or ISO code.
|
|
463
|
+
"""
|
|
464
|
+
geo_part = self._zoomeye_geo_part()
|
|
465
|
+
port_part = self._zoomeye_port_part()
|
|
466
|
+
city_part = self._zoomeye_city_part()
|
|
467
|
+
org_part = f' +org:"{self.params.org}"' if self.params.org else ''
|
|
468
|
+
model_part = f' +banner:"{self.params.model}"' if self.params.model else ''
|
|
469
|
+
suffix = f"{geo_part}{city_part}{org_part}{model_part}{port_part}"
|
|
470
|
+
|
|
471
|
+
queries: List[Dict[str, str]] = []
|
|
472
|
+
|
|
473
|
+
if self.params.vendors:
|
|
474
|
+
for vendor in self.params.vendors:
|
|
475
|
+
vendor_key = vendor.lower()
|
|
476
|
+
raw_terms = _VENDOR_SEARCH_TERMS.get(vendor_key, [f'"{vendor}"'])
|
|
477
|
+
zy_terms = [t.strip('"') for t in raw_terms[:2]]
|
|
478
|
+
for term in zy_terms:
|
|
479
|
+
q = f'banner:"{term}"{suffix}'
|
|
480
|
+
if not port_part:
|
|
481
|
+
q += ' +(port:9100 port:515 port:631)'
|
|
482
|
+
queries.append({
|
|
483
|
+
'query': q,
|
|
484
|
+
'description': f"{vendor.title()} printers (ZoomEye){self._geo_label()}",
|
|
485
|
+
'vendor': vendor,
|
|
486
|
+
})
|
|
487
|
+
else:
|
|
488
|
+
generic_terms = ['banner:"@PJL"', 'banner:"PJL READY"']
|
|
489
|
+
for term in generic_terms:
|
|
490
|
+
q = f'{term}{suffix}'
|
|
491
|
+
if not port_part:
|
|
492
|
+
q += ' +(port:9100 port:515)'
|
|
493
|
+
queries.append({
|
|
494
|
+
'query': q,
|
|
495
|
+
'description': f"Generic PJL printers (ZoomEye){self._geo_label()}",
|
|
496
|
+
'vendor': None,
|
|
497
|
+
})
|
|
498
|
+
return queries
|
|
499
|
+
|
|
500
|
+
def _zoomeye_geo_part(self) -> str:
|
|
501
|
+
codes = self.params.resolve_country_codes()
|
|
502
|
+
if not codes:
|
|
503
|
+
return ''
|
|
504
|
+
if len(codes) == 1:
|
|
505
|
+
return f' +country:"{codes[0]}"'
|
|
506
|
+
# ZoomEye supports OR with multiple country: filters
|
|
507
|
+
return ' +(' + ' '.join(f'country:"{c}"' for c in codes) + ')'
|
|
508
|
+
|
|
509
|
+
def _zoomeye_port_part(self) -> str:
|
|
510
|
+
if not self.params.ports:
|
|
511
|
+
return ''
|
|
512
|
+
if len(self.params.ports) == 1:
|
|
513
|
+
return f' +port:{self.params.ports[0]}'
|
|
514
|
+
return ' +(' + ' '.join(f'port:{p}' for p in self.params.ports) + ')'
|
|
515
|
+
|
|
516
|
+
def _zoomeye_city_part(self) -> str:
|
|
517
|
+
if not self.params.cities:
|
|
518
|
+
return ''
|
|
519
|
+
if len(self.params.cities) == 1:
|
|
520
|
+
return f' +city:"{self.params.cities[0]}"'
|
|
521
|
+
return ' +(' + ' '.join(f'city:"{c}"' for c in self.params.cities) + ')'
|
|
522
|
+
|
|
523
|
+
# ── Netlas ────────────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
def build_netlas_queries(self) -> List[Dict[str, str]]:
|
|
526
|
+
"""
|
|
527
|
+
Builds Netlas Lucene queries.
|
|
528
|
+
|
|
529
|
+
Verified field names (Netlas API 2026-03):
|
|
530
|
+
port port number
|
|
531
|
+
geo.country ISO-2 country code (e.g. "BR")
|
|
532
|
+
geo.city city name (string)
|
|
533
|
+
isp ISP/org name
|
|
534
|
+
http.title HTTP page title (for web-exposed printers)
|
|
535
|
+
protocol protocol name (e.g. "raw_tcp", "http")
|
|
536
|
+
|
|
537
|
+
Note: raw TCP ports (9100, 515) have no text banner — vendor matching
|
|
538
|
+
is done via http.title for web printers, or port-only for raw TCP.
|
|
539
|
+
Ref: https://docs.netlas.io/knowledge-base/field-reference/responses/
|
|
540
|
+
"""
|
|
541
|
+
geo_part = self._netlas_geo_part()
|
|
542
|
+
port_part = self._netlas_port_part()
|
|
543
|
+
city_part = self._netlas_city_part()
|
|
544
|
+
org_part = f' AND isp:"{self.params.org}"' if self.params.org else ''
|
|
545
|
+
# Model search uses http.title (web-exposed printers) when model specified
|
|
546
|
+
model_part = f' AND http.title:"{self.params.model}"' if self.params.model else ''
|
|
547
|
+
suffix = f"{geo_part}{city_part}{org_part}{model_part}{port_part}"
|
|
548
|
+
|
|
549
|
+
queries: List[Dict[str, str]] = []
|
|
550
|
+
|
|
551
|
+
if self.params.vendors:
|
|
552
|
+
for vendor in self.params.vendors:
|
|
553
|
+
vendor_key = vendor.lower()
|
|
554
|
+
raw_terms = _VENDOR_SEARCH_TERMS.get(vendor_key, [f'"{vendor}"'])
|
|
555
|
+
nl_terms = [t.strip('"') for t in raw_terms[:1]] # 1 term per vendor in Netlas
|
|
556
|
+
for term in nl_terms:
|
|
557
|
+
# Try http.title (web-exposed printers) first, then pure port filter
|
|
558
|
+
q_web = f'http.title:"{term}"{suffix}'
|
|
559
|
+
if not port_part:
|
|
560
|
+
q_web += ' AND (port:80 OR port:443 OR port:631)'
|
|
561
|
+
queries.append({
|
|
562
|
+
'query': q_web,
|
|
563
|
+
'description': f"{vendor.title()} printers via web (Netlas){self._geo_label()}",
|
|
564
|
+
'vendor': vendor,
|
|
565
|
+
})
|
|
566
|
+
# Also add a port-only fallback (broader, for raw TCP printers)
|
|
567
|
+
q_port = f'(port:9100 OR port:515){suffix}'
|
|
568
|
+
queries.append({
|
|
569
|
+
'query': q_port,
|
|
570
|
+
'description': f"{vendor.title()} raw-TCP printers (Netlas){self._geo_label()}",
|
|
571
|
+
'vendor': vendor,
|
|
572
|
+
})
|
|
573
|
+
else:
|
|
574
|
+
# No vendor: just search by printer ports
|
|
575
|
+
generic_terms = [
|
|
576
|
+
('(port:9100 OR port:515)', 'RAW/LPD printers'),
|
|
577
|
+
('(port:631 OR port:9100)', 'IPP/RAW printers'),
|
|
578
|
+
]
|
|
579
|
+
for q_base, desc in generic_terms:
|
|
580
|
+
q = f'{q_base}{suffix}'
|
|
581
|
+
queries.append({
|
|
582
|
+
'query': q,
|
|
583
|
+
'description': f"{desc} (Netlas){self._geo_label()}",
|
|
584
|
+
'vendor': None,
|
|
585
|
+
})
|
|
586
|
+
return queries
|
|
587
|
+
|
|
588
|
+
def _netlas_geo_part(self) -> str:
|
|
589
|
+
codes = self.params.resolve_country_codes()
|
|
590
|
+
if not codes:
|
|
591
|
+
return ''
|
|
592
|
+
if len(codes) == 1:
|
|
593
|
+
return f' AND geo.country:{codes[0]}'
|
|
594
|
+
return ' AND (' + ' OR '.join(f'geo.country:{c}' for c in codes) + ')'
|
|
595
|
+
|
|
596
|
+
def _netlas_port_part(self) -> str:
|
|
597
|
+
if not self.params.ports:
|
|
598
|
+
return ''
|
|
599
|
+
if len(self.params.ports) == 1:
|
|
600
|
+
return f' AND port:{self.params.ports[0]}'
|
|
601
|
+
return ' AND (' + ' OR '.join(f'port:{p}' for p in self.params.ports) + ')'
|
|
602
|
+
|
|
603
|
+
def _netlas_city_part(self) -> str:
|
|
604
|
+
if not self.params.cities:
|
|
605
|
+
return ''
|
|
606
|
+
if len(self.params.cities) == 1:
|
|
607
|
+
return f' AND geo.city:"{self.params.cities[0]}"'
|
|
608
|
+
return ' AND (' + ' OR '.join(f'geo.city:"{c}"' for c in self.params.cities) + ')'
|
|
609
|
+
|
|
610
|
+
# ── Labels ────────────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
def _geo_label(self) -> str:
|
|
613
|
+
parts = []
|
|
614
|
+
codes = self.params.resolve_country_codes()
|
|
615
|
+
if codes:
|
|
616
|
+
parts.append('/'.join(codes[:4]) + ('...' if len(codes) > 4 else ''))
|
|
617
|
+
if self.params.cities:
|
|
618
|
+
cities_label = '/'.join(self.params.cities[:3])
|
|
619
|
+
if len(self.params.cities) > 3:
|
|
620
|
+
cities_label += '...'
|
|
621
|
+
parts.append(cities_label)
|
|
622
|
+
if self.params.regions:
|
|
623
|
+
parts.append('+'.join(self.params.regions))
|
|
624
|
+
return (', ' + ', '.join(parts)) if parts else ''
|
|
625
|
+
|
|
626
|
+
def describe(self) -> str:
|
|
627
|
+
"""Human-readable summary of the query being built."""
|
|
628
|
+
parts = []
|
|
629
|
+
if self.params.vendors:
|
|
630
|
+
parts.append(f"vendors={','.join(self.params.vendors)}")
|
|
631
|
+
if self.params.model:
|
|
632
|
+
parts.append(f"model={self.params.model!r}")
|
|
633
|
+
codes = self.params.resolve_country_codes()
|
|
634
|
+
if codes:
|
|
635
|
+
label = ','.join(codes[:6]) + ('...' if len(codes) > 6 else '')
|
|
636
|
+
parts.append(f"countries=[{label}]")
|
|
637
|
+
if self.params.cities:
|
|
638
|
+
parts.append(f"cities=[{','.join(self.params.cities)}]")
|
|
639
|
+
if self.params.regions:
|
|
640
|
+
parts.append(f"regions={','.join(self.params.regions)}")
|
|
641
|
+
if self.params.ports:
|
|
642
|
+
labels = [_PORT_LABELS.get(p, str(p)) for p in self.params.ports]
|
|
643
|
+
parts.append(f"ports={','.join(labels)}")
|
|
644
|
+
if self.params.org:
|
|
645
|
+
parts.append(f"org={self.params.org!r}")
|
|
646
|
+
if self.params.cpe:
|
|
647
|
+
parts.append(f"cpe={self.params.cpe!r}")
|
|
648
|
+
return ' | '.join(parts) if parts else 'generic (all printers)'
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# ── Result model ─────────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
@dataclass
|
|
654
|
+
class PrinterHit:
|
|
655
|
+
"""A single discovered printer from Shodan or Censys."""
|
|
656
|
+
ip: str
|
|
657
|
+
port: int
|
|
658
|
+
source: str
|
|
659
|
+
country: str = ''
|
|
660
|
+
country_code: str = ''
|
|
661
|
+
city: str = ''
|
|
662
|
+
org: str = ''
|
|
663
|
+
hostnames: List[str] = field(default_factory=list)
|
|
664
|
+
banner: str = ''
|
|
665
|
+
product: str = ''
|
|
666
|
+
version: str = ''
|
|
667
|
+
vendor: str = ''
|
|
668
|
+
timestamp: str = ''
|
|
669
|
+
|
|
670
|
+
def to_dict(self) -> Dict:
|
|
671
|
+
return self.__dict__.copy()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ── Shodan search ─────────────────────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
class ShodanSearcher:
|
|
677
|
+
"""Thin wrapper around the Shodan API with structured-query support."""
|
|
678
|
+
|
|
679
|
+
def __init__(self, api_key: str) -> None:
|
|
680
|
+
if not SHODAN_AVAILABLE:
|
|
681
|
+
raise ImportError("shodan package not installed — pip install shodan")
|
|
682
|
+
self._api = _shodan_lib.Shodan(api_key)
|
|
683
|
+
|
|
684
|
+
def plan_info(self) -> Dict:
|
|
685
|
+
try:
|
|
686
|
+
return self._api.info()
|
|
687
|
+
except Exception:
|
|
688
|
+
return {}
|
|
689
|
+
|
|
690
|
+
def search(self, query: str, limit: int = 100, vendor: str = '') -> List[PrinterHit]:
|
|
691
|
+
hits: List[PrinterHit] = []
|
|
692
|
+
try:
|
|
693
|
+
_log.debug("Shodan query: %s", query)
|
|
694
|
+
result = self._api.search(query, limit=limit)
|
|
695
|
+
for m in result.get('matches', []):
|
|
696
|
+
loc = m.get('location', {})
|
|
697
|
+
hits.append(PrinterHit(
|
|
698
|
+
ip = m.get('ip_str', ''),
|
|
699
|
+
port = m.get('port', 9100),
|
|
700
|
+
source = 'shodan',
|
|
701
|
+
country = loc.get('country_name', ''),
|
|
702
|
+
country_code = loc.get('country_code', ''),
|
|
703
|
+
city = loc.get('city', ''),
|
|
704
|
+
org = m.get('org', ''),
|
|
705
|
+
hostnames = m.get('hostnames', []),
|
|
706
|
+
banner = (m.get('data', '') or '')[:600],
|
|
707
|
+
product = m.get('product', ''),
|
|
708
|
+
version = m.get('version', ''),
|
|
709
|
+
vendor = vendor,
|
|
710
|
+
timestamp = m.get('timestamp', ''),
|
|
711
|
+
))
|
|
712
|
+
except _shodan_lib.APIError as exc:
|
|
713
|
+
_log.warning("Shodan API error: %s", exc)
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
_log.warning("Shodan search error: %s", exc)
|
|
716
|
+
return hits
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ── Censys search ─────────────────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
class CensysSearcher:
|
|
722
|
+
"""Thin wrapper around Censys Hosts API with structured-query support."""
|
|
723
|
+
|
|
724
|
+
def __init__(self, api_id: str, api_secret: str) -> None:
|
|
725
|
+
if not CENSYS_AVAILABLE:
|
|
726
|
+
raise ImportError("censys package not installed — pip install censys")
|
|
727
|
+
self._api = CensysHosts(api_id, api_secret)
|
|
728
|
+
|
|
729
|
+
def search(self, query: str, limit: int = 100, vendor: str = '') -> List[PrinterHit]:
|
|
730
|
+
hits: List[PrinterHit] = []
|
|
731
|
+
count = 0
|
|
732
|
+
try:
|
|
733
|
+
_log.debug("Censys query: %s", query)
|
|
734
|
+
for page in self._api.search(query, per_page=min(100, limit), pages=-1):
|
|
735
|
+
for host in page:
|
|
736
|
+
if count >= limit:
|
|
737
|
+
break
|
|
738
|
+
loc = host.get('location', {})
|
|
739
|
+
asn = host.get('autonomous_system', {})
|
|
740
|
+
svcs = host.get('services', [{}])
|
|
741
|
+
port = svcs[0].get('port', 9100) if svcs else 9100
|
|
742
|
+
hits.append(PrinterHit(
|
|
743
|
+
ip = host.get('ip', ''),
|
|
744
|
+
port = port,
|
|
745
|
+
source = 'censys',
|
|
746
|
+
country = loc.get('country', ''),
|
|
747
|
+
country_code = loc.get('country_code', ''),
|
|
748
|
+
city = loc.get('city', ''),
|
|
749
|
+
org = asn.get('name', ''),
|
|
750
|
+
hostnames = host.get('names', []),
|
|
751
|
+
banner = str(svcs[0])[:600] if svcs else '',
|
|
752
|
+
product = '',
|
|
753
|
+
version = '',
|
|
754
|
+
vendor = vendor,
|
|
755
|
+
timestamp = host.get('last_updated_at', ''),
|
|
756
|
+
))
|
|
757
|
+
count += 1
|
|
758
|
+
if count >= limit:
|
|
759
|
+
break
|
|
760
|
+
except Exception as exc:
|
|
761
|
+
_log.warning("Censys search error: %s", exc)
|
|
762
|
+
return hits
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ── FOFA Searcher ─────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
class FOFASearcher:
|
|
768
|
+
"""
|
|
769
|
+
Wraps the FOFA API (fofa.info) for structured printer searches.
|
|
770
|
+
|
|
771
|
+
API endpoint: GET https://fofa.info/api/v1/search/all
|
|
772
|
+
Auth: API key only (email field was deprecated by FOFA in December 2023).
|
|
773
|
+
Query must be base64-encoded.
|
|
774
|
+
Docs: https://en.fofa.info/api
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
_BASE = "https://fofa.info/api/v1/search/all"
|
|
778
|
+
_FIELDS = "ip,port,country,region,city,org,host,os,banner,server,product,version"
|
|
779
|
+
|
|
780
|
+
def __init__(self, api_key: str, email: str = "") -> None:
|
|
781
|
+
"""Initialize FOFASearcher.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
api_key: FOFA API key (required).
|
|
785
|
+
email: Ignored — FOFA deprecated email auth in December 2023.
|
|
786
|
+
"""
|
|
787
|
+
if not REQUESTS_AVAILABLE:
|
|
788
|
+
raise RuntimeError("'requests' library required for FOFA — pip install requests")
|
|
789
|
+
self._api_key = api_key.strip()
|
|
790
|
+
import requests as _r
|
|
791
|
+
self._session = _r.Session()
|
|
792
|
+
|
|
793
|
+
def search(self, params: 'DiscoveryParams', builder: 'DorkQueryBuilder') -> List['PrinterHit']:
|
|
794
|
+
"""Execute all FOFA dork queries derived from params."""
|
|
795
|
+
hits: List[PrinterHit] = []
|
|
796
|
+
seen: set = set()
|
|
797
|
+
limit = params.limit
|
|
798
|
+
queries = builder.build_fofa_queries()
|
|
799
|
+
|
|
800
|
+
for qd in queries:
|
|
801
|
+
if len(hits) >= limit:
|
|
802
|
+
break
|
|
803
|
+
q_b64 = qd.get('query_b64', base64.b64encode(qd['query'].encode()).decode())
|
|
804
|
+
_log.debug("FOFA query: %s", qd['query'])
|
|
805
|
+
try:
|
|
806
|
+
resp = self._session.get(
|
|
807
|
+
self._BASE,
|
|
808
|
+
params={
|
|
809
|
+
'key': self._api_key,
|
|
810
|
+
'qbase64': q_b64,
|
|
811
|
+
'fields': self._FIELDS,
|
|
812
|
+
'size': min(limit - len(hits), 100),
|
|
813
|
+
'page': 1,
|
|
814
|
+
'full': 'false',
|
|
815
|
+
},
|
|
816
|
+
timeout=20,
|
|
817
|
+
)
|
|
818
|
+
resp.raise_for_status()
|
|
819
|
+
data = resp.json()
|
|
820
|
+
if data.get('error'):
|
|
821
|
+
_log.warning("FOFA API error: %s", data.get('errmsg', 'unknown'))
|
|
822
|
+
continue
|
|
823
|
+
for item in data.get('results', []):
|
|
824
|
+
if len(hits) >= limit:
|
|
825
|
+
break
|
|
826
|
+
# results: [ip, port, country, region, city, org, host, os, banner, server, product, version]
|
|
827
|
+
ip = item[0] if len(item) > 0 else ''
|
|
828
|
+
if not ip or ip in seen:
|
|
829
|
+
continue
|
|
830
|
+
seen.add(ip)
|
|
831
|
+
hits.append(PrinterHit(
|
|
832
|
+
ip = ip,
|
|
833
|
+
port = int(item[1]) if len(item) > 1 and item[1] else 9100,
|
|
834
|
+
source = 'fofa',
|
|
835
|
+
country = item[2] if len(item) > 2 else '',
|
|
836
|
+
country_code = item[2] if len(item) > 2 else '',
|
|
837
|
+
city = item[4] if len(item) > 4 else '',
|
|
838
|
+
org = item[5] if len(item) > 5 else '',
|
|
839
|
+
hostnames = [item[6]] if len(item) > 6 and item[6] else [],
|
|
840
|
+
banner = item[8] if len(item) > 8 else '',
|
|
841
|
+
product = item[10] if len(item) > 10 else '',
|
|
842
|
+
version = item[11] if len(item) > 11 else '',
|
|
843
|
+
vendor = qd.get('vendor'),
|
|
844
|
+
timestamp = '',
|
|
845
|
+
))
|
|
846
|
+
except Exception as exc:
|
|
847
|
+
_log.warning("FOFA search error (%s): %s", qd['query'][:60], exc)
|
|
848
|
+
return hits
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# ── ZoomEye Searcher ──────────────────────────────────────────────────────────
|
|
852
|
+
|
|
853
|
+
class ZoomEyeSearcher:
|
|
854
|
+
"""
|
|
855
|
+
Wraps the ZoomEye API for structured printer searches.
|
|
856
|
+
|
|
857
|
+
API endpoint: GET https://api.zoomeye.ai/host/search
|
|
858
|
+
Auth: API-KEY header (preferred over deprecated JWT since 2025).
|
|
859
|
+
Note: api.zoomeye.org redirects non-CN users to api.zoomeye.ai.
|
|
860
|
+
Docs: https://www.zoomeye.ai/doc
|
|
861
|
+
"""
|
|
862
|
+
|
|
863
|
+
_BASE = "https://api.zoomeye.ai/host/search"
|
|
864
|
+
|
|
865
|
+
def __init__(self, api_key: str) -> None:
|
|
866
|
+
if not REQUESTS_AVAILABLE:
|
|
867
|
+
raise RuntimeError("'requests' library required for ZoomEye — pip install requests")
|
|
868
|
+
self._api_key = api_key.strip()
|
|
869
|
+
import requests as _r
|
|
870
|
+
self._session = _r.Session()
|
|
871
|
+
self._session.headers.update({
|
|
872
|
+
'API-KEY': self._api_key,
|
|
873
|
+
'Content-Type': 'application/json',
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
def search(self, params: 'DiscoveryParams', builder: 'DorkQueryBuilder') -> List['PrinterHit']:
|
|
877
|
+
"""Execute all ZoomEye dork queries derived from params."""
|
|
878
|
+
hits: List[PrinterHit] = []
|
|
879
|
+
seen: set = set()
|
|
880
|
+
limit = params.limit
|
|
881
|
+
queries = builder.build_zoomeye_queries()
|
|
882
|
+
|
|
883
|
+
for qd in queries:
|
|
884
|
+
if len(hits) >= limit:
|
|
885
|
+
break
|
|
886
|
+
_log.debug("ZoomEye query: %s", qd['query'])
|
|
887
|
+
try:
|
|
888
|
+
page = 1
|
|
889
|
+
while len(hits) < limit:
|
|
890
|
+
resp = self._session.get(
|
|
891
|
+
self._BASE,
|
|
892
|
+
params={
|
|
893
|
+
'query': qd['query'],
|
|
894
|
+
'page': page,
|
|
895
|
+
'pagesize': 20,
|
|
896
|
+
},
|
|
897
|
+
timeout=20,
|
|
898
|
+
)
|
|
899
|
+
resp.raise_for_status()
|
|
900
|
+
data = resp.json()
|
|
901
|
+
items = data.get('matches', [])
|
|
902
|
+
if not items:
|
|
903
|
+
break
|
|
904
|
+
for item in items:
|
|
905
|
+
if len(hits) >= limit:
|
|
906
|
+
break
|
|
907
|
+
ip = item.get('ip', '')
|
|
908
|
+
if not ip or ip in seen:
|
|
909
|
+
continue
|
|
910
|
+
seen.add(ip)
|
|
911
|
+
geo = item.get('geoinfo', {})
|
|
912
|
+
pinfo = item.get('portinfo', {})
|
|
913
|
+
hits.append(PrinterHit(
|
|
914
|
+
ip = ip,
|
|
915
|
+
port = int(pinfo.get('port', 9100)),
|
|
916
|
+
source = 'zoomeye',
|
|
917
|
+
country = geo.get('country', {}).get('name', ''),
|
|
918
|
+
country_code = geo.get('country', {}).get('code', ''),
|
|
919
|
+
city = geo.get('city', {}).get('name', ''),
|
|
920
|
+
org = geo.get('organization', ''),
|
|
921
|
+
hostnames = [item.get('rdns', '')] if item.get('rdns') else [],
|
|
922
|
+
banner = pinfo.get('banner', '')[:600],
|
|
923
|
+
product = pinfo.get('app', ''),
|
|
924
|
+
version = pinfo.get('ver', ''),
|
|
925
|
+
vendor = qd.get('vendor'),
|
|
926
|
+
timestamp = item.get('timestamp', ''),
|
|
927
|
+
))
|
|
928
|
+
# ZoomEye free plan limited; stop after first page unless we have quota
|
|
929
|
+
if len(items) < 20:
|
|
930
|
+
break
|
|
931
|
+
page += 1
|
|
932
|
+
except Exception as exc:
|
|
933
|
+
msg = str(exc)
|
|
934
|
+
if '402' in msg or 'credits' in msg.lower():
|
|
935
|
+
_log.warning("ZoomEye: insufficient API credits — upgrade plan at https://www.zoomeye.ai")
|
|
936
|
+
break
|
|
937
|
+
_log.warning("ZoomEye search error (%s): %s", qd['query'][:60], exc)
|
|
938
|
+
return hits
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ── Netlas Searcher ───────────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
class NetlasSearcher:
|
|
944
|
+
"""
|
|
945
|
+
Wraps the Netlas API (netlas.io) for structured printer searches.
|
|
946
|
+
|
|
947
|
+
API endpoint: GET https://app.netlas.io/api/responses/
|
|
948
|
+
Auth: X-API-Key header.
|
|
949
|
+
Query syntax: Lucene (Elasticsearch-based).
|
|
950
|
+
Docs: https://docs.netlas.io/
|
|
951
|
+
"""
|
|
952
|
+
|
|
953
|
+
_BASE = "https://app.netlas.io/api/responses/"
|
|
954
|
+
|
|
955
|
+
def __init__(self, api_key: str) -> None:
|
|
956
|
+
if not REQUESTS_AVAILABLE:
|
|
957
|
+
raise RuntimeError("'requests' library required for Netlas — pip install requests")
|
|
958
|
+
self._api_key = api_key.strip()
|
|
959
|
+
import requests as _r
|
|
960
|
+
self._session = _r.Session()
|
|
961
|
+
self._session.headers.update({'X-API-Key': self._api_key})
|
|
962
|
+
|
|
963
|
+
def search(self, params: 'DiscoveryParams', builder: 'DorkQueryBuilder') -> List['PrinterHit']:
|
|
964
|
+
"""Execute all Netlas Lucene queries derived from params.
|
|
965
|
+
|
|
966
|
+
Netlas field reference (verified 2026-03):
|
|
967
|
+
geo.country ISO-2 country code (e.g. "BR")
|
|
968
|
+
geo.city city name (string)
|
|
969
|
+
port port number (integer)
|
|
970
|
+
isp ISP name
|
|
971
|
+
host hostname/IP string
|
|
972
|
+
@timestamp scan timestamp
|
|
973
|
+
"""
|
|
974
|
+
hits: List[PrinterHit] = []
|
|
975
|
+
seen: set = set()
|
|
976
|
+
limit = params.limit
|
|
977
|
+
queries = builder.build_netlas_queries()
|
|
978
|
+
|
|
979
|
+
for qd in queries:
|
|
980
|
+
if len(hits) >= limit:
|
|
981
|
+
break
|
|
982
|
+
_log.debug("Netlas query: %s", qd['query'])
|
|
983
|
+
try:
|
|
984
|
+
resp = self._session.get(
|
|
985
|
+
self._BASE,
|
|
986
|
+
params={
|
|
987
|
+
'q': qd['query'],
|
|
988
|
+
'size': min(limit - len(hits), 100),
|
|
989
|
+
},
|
|
990
|
+
timeout=60, # Netlas can be slow on country-filtered queries
|
|
991
|
+
)
|
|
992
|
+
resp.raise_for_status()
|
|
993
|
+
data = resp.json()
|
|
994
|
+
items = data.get('items', [])
|
|
995
|
+
for item in items:
|
|
996
|
+
if len(hits) >= limit:
|
|
997
|
+
break
|
|
998
|
+
attrs = item.get('data', {})
|
|
999
|
+
ip = attrs.get('ip', '')
|
|
1000
|
+
if not ip or ip in seen:
|
|
1001
|
+
continue
|
|
1002
|
+
seen.add(ip)
|
|
1003
|
+
geo = attrs.get('geo', {})
|
|
1004
|
+
hits.append(PrinterHit(
|
|
1005
|
+
ip = ip,
|
|
1006
|
+
port = int(attrs.get('port', 9100)),
|
|
1007
|
+
source = 'netlas',
|
|
1008
|
+
country = geo.get('country', ''),
|
|
1009
|
+
country_code = geo.get('country', ''),
|
|
1010
|
+
city = geo.get('city', '') if isinstance(geo.get('city'), str) else '',
|
|
1011
|
+
org = attrs.get('isp', ''),
|
|
1012
|
+
hostnames = [attrs.get('host', '')] if attrs.get('host') else [],
|
|
1013
|
+
banner = '', # raw TCP has no banner; HTTP captured separately
|
|
1014
|
+
product = '',
|
|
1015
|
+
version = '',
|
|
1016
|
+
vendor = qd.get('vendor'),
|
|
1017
|
+
timestamp = attrs.get('@timestamp', ''),
|
|
1018
|
+
))
|
|
1019
|
+
except Exception as exc:
|
|
1020
|
+
_log.warning("Netlas search error (%s): %s", qd['query'][:60], exc)
|
|
1021
|
+
return hits
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
# ── Manager (main public API) ─────────────────────────────────────────────────
|
|
1025
|
+
|
|
1026
|
+
class OnlineDiscoveryManager:
|
|
1027
|
+
"""
|
|
1028
|
+
Orchestrates dork-based printer discovery across Shodan, Censys, FOFA, ZoomEye, Netlas.
|
|
1029
|
+
|
|
1030
|
+
Usage:
|
|
1031
|
+
params = DiscoveryParams(vendors=['epson'], regions=['latin_america'], ports=[515])
|
|
1032
|
+
mgr = OnlineDiscoveryManager() # loads keys from config.json
|
|
1033
|
+
hits = mgr.targeted_search(params, engines=['shodan','fofa'])
|
|
1034
|
+
mgr.print_results(hits)
|
|
1035
|
+
"""
|
|
1036
|
+
|
|
1037
|
+
# ── ANSI colours ──────────────────────────────────────────────────────────
|
|
1038
|
+
_GRN = '\033[0;32m'
|
|
1039
|
+
_YEL = '\033[1;33m'
|
|
1040
|
+
_CYN = '\033[1;36m'
|
|
1041
|
+
_RED = '\033[1;31m'
|
|
1042
|
+
_DIM = '\033[2;37m'
|
|
1043
|
+
_RST = '\033[0m'
|
|
1044
|
+
|
|
1045
|
+
_ALL_ENGINES = ('shodan', 'censys', 'fofa', 'zoomeye', 'netlas')
|
|
1046
|
+
|
|
1047
|
+
def __init__(self,
|
|
1048
|
+
shodan_key: Optional[str] = None,
|
|
1049
|
+
censys_id: Optional[str] = None,
|
|
1050
|
+
censys_secret: Optional[str] = None,
|
|
1051
|
+
fofa_key: Optional[str] = None,
|
|
1052
|
+
zoomeye_key: Optional[str] = None,
|
|
1053
|
+
netlas_key: Optional[str] = None) -> None:
|
|
1054
|
+
|
|
1055
|
+
# Load credentials from config when not passed directly
|
|
1056
|
+
try:
|
|
1057
|
+
from utils.config import (load_config, shodan_key as _sk, censys_credentials,
|
|
1058
|
+
fofa_key as _fk, zoomeye_key as _zk, netlas_key as _nk)
|
|
1059
|
+
load_config()
|
|
1060
|
+
if not shodan_key:
|
|
1061
|
+
shodan_key = _sk()
|
|
1062
|
+
if not (censys_id and censys_secret):
|
|
1063
|
+
censys_id, censys_secret = censys_credentials()
|
|
1064
|
+
if not fofa_key:
|
|
1065
|
+
fofa_key = _fk()
|
|
1066
|
+
if not zoomeye_key:
|
|
1067
|
+
zoomeye_key = _zk()
|
|
1068
|
+
if not netlas_key:
|
|
1069
|
+
netlas_key = _nk()
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1072
|
+
|
|
1073
|
+
self._shodan: Optional[ShodanSearcher] = None
|
|
1074
|
+
self._censys: Optional[CensysSearcher] = None
|
|
1075
|
+
self._fofa: Optional[FOFASearcher] = None
|
|
1076
|
+
self._zoomeye: Optional[ZoomEyeSearcher] = None
|
|
1077
|
+
self._netlas: Optional[NetlasSearcher] = None
|
|
1078
|
+
self._hits: List[PrinterHit] = []
|
|
1079
|
+
|
|
1080
|
+
if shodan_key:
|
|
1081
|
+
try:
|
|
1082
|
+
self._shodan = ShodanSearcher(shodan_key)
|
|
1083
|
+
_log.info("Shodan API initialized")
|
|
1084
|
+
except Exception as exc:
|
|
1085
|
+
print(f" {self._YEL}[!]{self._RST} Shodan init failed: {exc}")
|
|
1086
|
+
|
|
1087
|
+
if censys_id and censys_secret:
|
|
1088
|
+
try:
|
|
1089
|
+
self._censys = CensysSearcher(censys_id, censys_secret)
|
|
1090
|
+
_log.info("Censys API initialized")
|
|
1091
|
+
except Exception as exc:
|
|
1092
|
+
print(f" {self._YEL}[!]{self._RST} Censys init failed: {exc}")
|
|
1093
|
+
|
|
1094
|
+
if fofa_key:
|
|
1095
|
+
try:
|
|
1096
|
+
self._fofa = FOFASearcher(api_key=fofa_key)
|
|
1097
|
+
_log.info("FOFA API initialized")
|
|
1098
|
+
except Exception as exc:
|
|
1099
|
+
print(f" {self._YEL}[!]{self._RST} FOFA init failed: {exc}")
|
|
1100
|
+
|
|
1101
|
+
if zoomeye_key:
|
|
1102
|
+
try:
|
|
1103
|
+
self._zoomeye = ZoomEyeSearcher(zoomeye_key)
|
|
1104
|
+
_log.info("ZoomEye API initialized")
|
|
1105
|
+
except Exception as exc:
|
|
1106
|
+
print(f" {self._YEL}[!]{self._RST} ZoomEye init failed: {exc}")
|
|
1107
|
+
|
|
1108
|
+
if netlas_key:
|
|
1109
|
+
try:
|
|
1110
|
+
self._netlas = NetlasSearcher(netlas_key)
|
|
1111
|
+
_log.info("Netlas API initialized")
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
print(f" {self._YEL}[!]{self._RST} Netlas init failed: {exc}")
|
|
1114
|
+
|
|
1115
|
+
# ── Public entry points ───────────────────────────────────────────────────
|
|
1116
|
+
|
|
1117
|
+
def _available_engines(self) -> Dict[str, object]:
|
|
1118
|
+
"""Return a dict of engine_name -> searcher for all initialized engines."""
|
|
1119
|
+
return {k: v for k, v in {
|
|
1120
|
+
'shodan': self._shodan,
|
|
1121
|
+
'censys': self._censys,
|
|
1122
|
+
'fofa': self._fofa,
|
|
1123
|
+
'zoomeye': self._zoomeye,
|
|
1124
|
+
'netlas': self._netlas,
|
|
1125
|
+
}.items() if v is not None}
|
|
1126
|
+
|
|
1127
|
+
def targeted_search(self,
|
|
1128
|
+
params: DiscoveryParams,
|
|
1129
|
+
engines: Optional[List[str]] = None) -> List['PrinterHit']:
|
|
1130
|
+
"""
|
|
1131
|
+
Run a structured dork search using provided parameters.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
params: Filter criteria (vendor, country, port, etc.).
|
|
1135
|
+
engines: Whitelist of engine names to use, e.g. ['shodan','fofa'].
|
|
1136
|
+
Defaults to all initialized engines.
|
|
1137
|
+
|
|
1138
|
+
Raises:
|
|
1139
|
+
ValueError: if params.has_filters() returns False.
|
|
1140
|
+
Returns:
|
|
1141
|
+
Deduplicated list of PrinterHit, sorted by country + IP.
|
|
1142
|
+
"""
|
|
1143
|
+
if not params.has_filters():
|
|
1144
|
+
raise ValueError(
|
|
1145
|
+
"At least one discovery filter is required: --dork-vendor, --dork-country, "
|
|
1146
|
+
"--dork-city, --dork-region, --dork-port, --dork-org, --dork-cpe, or --dork-model\n"
|
|
1147
|
+
"When searching without geographic/vendor filters, provide a direct IP target instead."
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
active = self._available_engines()
|
|
1151
|
+
if engines:
|
|
1152
|
+
active = {k: v for k, v in active.items()
|
|
1153
|
+
if k in [e.lower().strip() for e in engines]}
|
|
1154
|
+
|
|
1155
|
+
builder = DorkQueryBuilder(params)
|
|
1156
|
+
print(f"\n {self._CYN}{'='*68}{self._RST}")
|
|
1157
|
+
print(f" {self._CYN}Online Printer Discovery — Dork Mode{self._RST}")
|
|
1158
|
+
print(f" Filter : {builder.describe()}")
|
|
1159
|
+
print(f" Engines: {', '.join(active.keys()) or 'none configured'}")
|
|
1160
|
+
print(f" Limit : {params.limit} results per query")
|
|
1161
|
+
print(f" {self._CYN}{'='*68}{self._RST}\n")
|
|
1162
|
+
|
|
1163
|
+
all_hits: List[PrinterHit] = []
|
|
1164
|
+
delay = 1.2 # seconds between API calls
|
|
1165
|
+
|
|
1166
|
+
_ENGINE_DISPATCH = {
|
|
1167
|
+
'shodan': self._run_shodan,
|
|
1168
|
+
'censys': self._run_censys,
|
|
1169
|
+
'fofa': self._run_fofa,
|
|
1170
|
+
'zoomeye': self._run_zoomeye,
|
|
1171
|
+
'netlas': self._run_netlas,
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
for eng_name, _searcher in active.items():
|
|
1175
|
+
hits = _ENGINE_DISPATCH[eng_name](builder, params)
|
|
1176
|
+
all_hits.extend(hits)
|
|
1177
|
+
time.sleep(delay)
|
|
1178
|
+
|
|
1179
|
+
if not active:
|
|
1180
|
+
print(f" {self._RED}[!]{self._RST} No API credentials configured.")
|
|
1181
|
+
print(f" Add keys to config.json — see: python printerxpl-forge.py --check-config")
|
|
1182
|
+
return []
|
|
1183
|
+
|
|
1184
|
+
# Deduplicate by IP:port
|
|
1185
|
+
seen: Set[Tuple[str, int]] = set()
|
|
1186
|
+
unique: List[PrinterHit] = []
|
|
1187
|
+
for h in all_hits:
|
|
1188
|
+
key = (h.ip, h.port)
|
|
1189
|
+
if key not in seen:
|
|
1190
|
+
seen.add(key)
|
|
1191
|
+
unique.append(h)
|
|
1192
|
+
|
|
1193
|
+
unique.sort(key=lambda h: (h.country_code, h.ip))
|
|
1194
|
+
self._hits = unique
|
|
1195
|
+
return unique
|
|
1196
|
+
|
|
1197
|
+
# ── Per-engine runners ────────────────────────────────────────────────────
|
|
1198
|
+
|
|
1199
|
+
def _run_shodan(self, builder: 'DorkQueryBuilder', params: DiscoveryParams) -> List['PrinterHit']:
|
|
1200
|
+
if not self._shodan:
|
|
1201
|
+
return []
|
|
1202
|
+
queries = builder.build_shodan_queries()
|
|
1203
|
+
print(f" {self._GRN}[Shodan]{self._RST} {len(queries)} quer{'y' if len(queries)==1 else 'ies'}")
|
|
1204
|
+
hits: List[PrinterHit] = []
|
|
1205
|
+
for idx, q in enumerate(queries, 1):
|
|
1206
|
+
print(f" [{idx}/{len(queries)}] {q['description']}")
|
|
1207
|
+
print(f" {self._DIM}{q['query']}{self._RST}")
|
|
1208
|
+
h = self._shodan.search(q['query'], limit=params.limit, vendor=q.get('vendor', ''))
|
|
1209
|
+
print(f" Found: {self._GRN}{len(h)}{self._RST}")
|
|
1210
|
+
hits.extend(h)
|
|
1211
|
+
if idx < len(queries):
|
|
1212
|
+
time.sleep(1.2)
|
|
1213
|
+
return hits
|
|
1214
|
+
|
|
1215
|
+
def _run_censys(self, builder: 'DorkQueryBuilder', params: DiscoveryParams) -> List['PrinterHit']:
|
|
1216
|
+
if not self._censys:
|
|
1217
|
+
return []
|
|
1218
|
+
queries = builder.build_censys_queries()
|
|
1219
|
+
print(f" {self._GRN}[Censys]{self._RST} {len(queries)} quer{'y' if len(queries)==1 else 'ies'}")
|
|
1220
|
+
hits: List[PrinterHit] = []
|
|
1221
|
+
for idx, q in enumerate(queries, 1):
|
|
1222
|
+
print(f" [{idx}/{len(queries)}] {q['description']}")
|
|
1223
|
+
print(f" {self._DIM}{q['query']}{self._RST}")
|
|
1224
|
+
h = self._censys.search(q['query'], limit=params.limit, vendor=q.get('vendor', ''))
|
|
1225
|
+
print(f" Found: {self._GRN}{len(h)}{self._RST}")
|
|
1226
|
+
hits.extend(h)
|
|
1227
|
+
if idx < len(queries):
|
|
1228
|
+
time.sleep(1.2)
|
|
1229
|
+
return hits
|
|
1230
|
+
|
|
1231
|
+
def _run_fofa(self, builder: 'DorkQueryBuilder', params: DiscoveryParams) -> List['PrinterHit']:
|
|
1232
|
+
if not self._fofa:
|
|
1233
|
+
return []
|
|
1234
|
+
queries = builder.build_fofa_queries()
|
|
1235
|
+
print(f" {self._GRN}[FOFA]{self._RST} {len(queries)} quer{'y' if len(queries)==1 else 'ies'}")
|
|
1236
|
+
hits = self._fofa.search(params, builder)
|
|
1237
|
+
print(f" Found: {self._GRN}{len(hits)}{self._RST}")
|
|
1238
|
+
for q in queries:
|
|
1239
|
+
print(f" Query: {self._DIM}{q['query']}{self._RST}")
|
|
1240
|
+
return hits
|
|
1241
|
+
|
|
1242
|
+
def _run_zoomeye(self, builder: 'DorkQueryBuilder', params: DiscoveryParams) -> List['PrinterHit']:
|
|
1243
|
+
if not self._zoomeye:
|
|
1244
|
+
return []
|
|
1245
|
+
queries = builder.build_zoomeye_queries()
|
|
1246
|
+
print(f" {self._GRN}[ZoomEye]{self._RST} {len(queries)} quer{'y' if len(queries)==1 else 'ies'}")
|
|
1247
|
+
hits = self._zoomeye.search(params, builder)
|
|
1248
|
+
print(f" Found: {self._GRN}{len(hits)}{self._RST}")
|
|
1249
|
+
for q in queries:
|
|
1250
|
+
print(f" Query: {self._DIM}{q['query']}{self._RST}")
|
|
1251
|
+
return hits
|
|
1252
|
+
|
|
1253
|
+
def _run_netlas(self, builder: 'DorkQueryBuilder', params: DiscoveryParams) -> List['PrinterHit']:
|
|
1254
|
+
if not self._netlas:
|
|
1255
|
+
return []
|
|
1256
|
+
queries = builder.build_netlas_queries()
|
|
1257
|
+
print(f" {self._GRN}[Netlas]{self._RST} {len(queries)} quer{'y' if len(queries)==1 else 'ies'}")
|
|
1258
|
+
hits = self._netlas.search(params, builder)
|
|
1259
|
+
print(f" Found: {self._GRN}{len(hits)}{self._RST}")
|
|
1260
|
+
for q in queries:
|
|
1261
|
+
print(f" Query: {self._DIM}{q['query']}{self._RST}")
|
|
1262
|
+
return hits
|
|
1263
|
+
|
|
1264
|
+
def print_results(self, hits: List[PrinterHit]) -> None:
|
|
1265
|
+
"""Print a formatted table of discovered printers."""
|
|
1266
|
+
if not hits:
|
|
1267
|
+
print(f" {self._YEL}[!]{self._RST} No printers found matching the given filters.")
|
|
1268
|
+
return
|
|
1269
|
+
|
|
1270
|
+
print(f"\n {self._CYN}{'='*68}{self._RST}")
|
|
1271
|
+
print(f" {self._CYN}Results — {len(hits)} unique printer(s) found{self._RST}")
|
|
1272
|
+
print(f" {self._CYN}{'='*68}{self._RST}")
|
|
1273
|
+
print(f" {'IP':<16} {'Port':<6} {'CC':<4} {'City':<18} {'Org':<28} {'Src':<8}")
|
|
1274
|
+
print(f" {'-'*68}")
|
|
1275
|
+
|
|
1276
|
+
for h in hits:
|
|
1277
|
+
port_label = _PORT_LABELS.get(h.port, str(h.port))
|
|
1278
|
+
org_short = (h.org[:26] + '..') if len(h.org) > 28 else h.org
|
|
1279
|
+
city_short = (h.city[:16] + '..') if len(h.city) > 18 else h.city
|
|
1280
|
+
print(f" {self._GRN}{h.ip:<16}{self._RST} {port_label:<6} {h.country_code:<4} "
|
|
1281
|
+
f"{city_short:<18} {org_short:<28} {h.source}")
|
|
1282
|
+
|
|
1283
|
+
print()
|
|
1284
|
+
# Country stats
|
|
1285
|
+
by_cc: Dict[str, int] = defaultdict(int)
|
|
1286
|
+
for h in hits:
|
|
1287
|
+
by_cc[h.country_code or '??'] += 1
|
|
1288
|
+
stat_line = ' '.join(f"{cc}:{n}" for cc, n in sorted(by_cc.items(), key=lambda x: -x[1])[:10])
|
|
1289
|
+
print(f" Distribution: {stat_line}")
|
|
1290
|
+
print()
|
|
1291
|
+
|
|
1292
|
+
def export_results(self, hits: List[PrinterHit], output_path: Optional[str] = None) -> Optional[str]:
|
|
1293
|
+
"""Export results to JSON. Returns path to saved file."""
|
|
1294
|
+
if not hits:
|
|
1295
|
+
return None
|
|
1296
|
+
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
1297
|
+
path = output_path or f'.log/discovery_{ts}.json'
|
|
1298
|
+
os.makedirs(os.path.dirname(path) or '.', exist_ok=True)
|
|
1299
|
+
data = {
|
|
1300
|
+
'generated': datetime.now().isoformat(),
|
|
1301
|
+
'total': len(hits),
|
|
1302
|
+
'results': [h.to_dict() for h in hits],
|
|
1303
|
+
}
|
|
1304
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
1305
|
+
json.dump(data, f, indent=2)
|
|
1306
|
+
print(f" {self._GRN}[+]{self._RST} Results saved to {path}")
|
|
1307
|
+
return path
|
|
1308
|
+
|
|
1309
|
+
# ── Legacy compatibility shim (for old --discover-online without dorks) ───
|
|
1310
|
+
|
|
1311
|
+
def discover(self, max_results_per_query: int = 100,
|
|
1312
|
+
delay_between_queries: float = 1.2,
|
|
1313
|
+
use_shodan: bool = True,
|
|
1314
|
+
use_censys: bool = True) -> Dict:
|
|
1315
|
+
"""
|
|
1316
|
+
Legacy broad discovery (no filters).
|
|
1317
|
+
Deprecated — use targeted_search(DiscoveryParams(...)) instead.
|
|
1318
|
+
"""
|
|
1319
|
+
params = DiscoveryParams(
|
|
1320
|
+
vendors=list(_VENDOR_SEARCH_TERMS.keys())[:6],
|
|
1321
|
+
ports=[9100],
|
|
1322
|
+
limit=max_results_per_query,
|
|
1323
|
+
)
|
|
1324
|
+
hits = self.targeted_search(params)
|
|
1325
|
+
self.print_results(hits)
|
|
1326
|
+
self.export_results(hits)
|
|
1327
|
+
return {'total_devices': len(hits), 'timestamp': datetime.now().isoformat()}
|