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.
Files changed (97) hide show
  1. nse/README.md +204 -0
  2. nse/__init__.py +6 -0
  3. nse/install_nse.py +412 -0
  4. nse/lib/printerxpl.lua +238 -0
  5. nse/scripts/cups-info.nse +74 -0
  6. nse/scripts/cups-queue-info.nse +43 -0
  7. nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
  8. nse/scripts/http-device-mac.nse +107 -0
  9. nse/scripts/http-hp-ilo-info.nse +121 -0
  10. nse/scripts/http-info-xerox-enum.nse +101 -0
  11. nse/scripts/http-vuln-cve2022-1026.nse +158 -0
  12. nse/scripts/lexmark-config.nse +89 -0
  13. nse/scripts/pjl-ready-message.nse +106 -0
  14. nse/scripts/printer-banner.nse +217 -0
  15. nse/scripts/printer-cups-rce.nse +189 -0
  16. nse/scripts/printer-cve-detect.nse +279 -0
  17. nse/scripts/printer-discover.nse +205 -0
  18. nse/scripts/printer-firmware-exposed.nse +219 -0
  19. nse/scripts/printer-hp-pjl.nse +192 -0
  20. nse/scripts/printer-http-ews.nse +293 -0
  21. nse/scripts/printer-ipp-info.nse +235 -0
  22. nse/scripts/printer-lexmark-ipp.nse +203 -0
  23. nse/scripts/printer-passback.nse +204 -0
  24. nse/scripts/printer-pjl-info.nse +146 -0
  25. nse/scripts/printer-printnightmare.nse +211 -0
  26. nse/scripts/printer-snmp-info.nse +176 -0
  27. nse/scripts/printer-vuln-check.nse +256 -0
  28. nse/scripts/snmp-device-mac.nse +93 -0
  29. nse/scripts/snmp-info.nse +146 -0
  30. nse/scripts/snmp-sysdescr.nse +70 -0
  31. printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
  32. printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
  33. printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
  34. printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
  35. printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
  36. printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
  37. src/assets/fonts/gunplay.pfa +1671 -0
  38. src/assets/fonts/kshandwrt.pfa +315 -0
  39. src/assets/fonts/laksoner.pfa +2402 -0
  40. src/assets/fonts/paintcans.pfa +9699 -0
  41. src/assets/fonts/stencilod.pfa +4076 -0
  42. src/assets/fonts/takecover.pfa +26138 -0
  43. src/assets/fonts/topsecret.pfa +6652 -0
  44. src/assets/fonts/whoa.pfa +773 -0
  45. src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
  46. src/assets/mibs/Printer-MIB +4389 -0
  47. src/assets/mibs/README.md +9 -0
  48. src/assets/mibs/SNMPv2-MIB +854 -0
  49. src/assets/overlays/hacker.eps +596 -0
  50. src/assets/overlays/smiley.eps +214 -0
  51. src/assets/overlays/smiley2.eps +240 -0
  52. src/core/attack_orchestrator.py +1025 -0
  53. src/core/capabilities.py +323 -0
  54. src/core/destructive_audit.py +430 -0
  55. src/core/discovery.py +488 -0
  56. src/core/osdetect.py +74 -0
  57. src/core/poly_runner.py +579 -0
  58. src/core/printer.py +1426 -0
  59. src/main.py +2134 -0
  60. src/modules/install_printer.py +318 -0
  61. src/modules/login_bruteforce.py +852 -0
  62. src/modules/pcl.py +506 -0
  63. src/modules/pjl.py +3575 -0
  64. src/modules/print_job.py +1290 -0
  65. src/modules/ps.py +1102 -0
  66. src/payloads/__init__.py +98 -0
  67. src/payloads/assets/overlays/notice.eps +9 -0
  68. src/protocols/__init__.py +19 -0
  69. src/protocols/firmware.py +738 -0
  70. src/protocols/ipp.py +216 -0
  71. src/protocols/ipp_attacks.py +609 -0
  72. src/protocols/lpd.py +141 -0
  73. src/protocols/network_map.py +1004 -0
  74. src/protocols/raw.py +173 -0
  75. src/protocols/smb.py +359 -0
  76. src/protocols/ssrf_pivot.py +427 -0
  77. src/protocols/storage.py +587 -0
  78. src/ui/__init__.py +6 -0
  79. src/ui/interactive.py +742 -0
  80. src/ui/spinner.py +112 -0
  81. src/ui/tables.py +132 -0
  82. src/utils/banner_grabber.py +852 -0
  83. src/utils/codebook.py +456 -0
  84. src/utils/config.py +522 -0
  85. src/utils/cve_loader.py +158 -0
  86. src/utils/default_creds.py +134 -0
  87. src/utils/discovery_online.py +1327 -0
  88. src/utils/exploit_manager.py +805 -0
  89. src/utils/fuzzer.py +220 -0
  90. src/utils/helper.py +732 -0
  91. src/utils/local_printers.py +307 -0
  92. src/utils/ml_engine.py +491 -0
  93. src/utils/operators.py +474 -0
  94. src/utils/ports.py +234 -0
  95. src/utils/vuln_scanner.py +823 -0
  96. src/utils/wordlist_loader.py +412 -0
  97. 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()}