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,823 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ PrinterXPL-Forge — Vulnerability Scanner
5
+ =======================================
6
+ Matches printer fingerprints against known CVEs with strict specificity levels:
7
+
8
+ Level 0 — Exact: make + model + firmware match
9
+ Level 1 — Model: make + model match (any firmware)
10
+ Level 2 — Series: make + model-family/series match
11
+ Level 3 — Vendor: make only — clearly labelled as generic vendor advisory
12
+ Level 4 — Generic: applies to all printers regardless of vendor (PJL, SNMP…)
13
+
14
+ Results are presented separately by specificity level.
15
+ If nothing is found for levels 0–2, the report says so explicitly.
16
+ Generic (vendor-wide and protocol) advisories are shown in a separate section.
17
+
18
+ NVD API v2 queries use targeted CPE-style keywords to reduce false positives.
19
+ Only CVEs whose description or references mention the specific model/series
20
+ are promoted to the model-specific section; the rest are shown as "related vendor
21
+ advisories" with a clear note.
22
+ """
23
+
24
+ # Author : Andre Henrique (@mrhenrike)
25
+ # GitHub : https://github.com/mrhenrike
26
+ # LinkedIn : https://linkedin.com/in/mrhenrike
27
+ # X/Twitter : https://x.com/mrhenrike
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import re
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from enum import IntEnum
36
+ from typing import Dict, List, Optional, Tuple
37
+
38
+ import requests
39
+ import urllib3
40
+
41
+ urllib3.disable_warnings()
42
+
43
+ # Local CVE catalog — consulted before NVD API for offline / faster lookups
44
+ try:
45
+ from utils.cve_loader import lookup as _local_cve_lookup, filter_by as _local_filter
46
+ except ImportError:
47
+ try:
48
+ from cve_loader import lookup as _local_cve_lookup, filter_by as _local_filter
49
+ except ImportError:
50
+ _local_cve_lookup = None # type: ignore
51
+ _local_filter = None # type: ignore
52
+
53
+ _log = logging.getLogger(__name__)
54
+
55
+ NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
56
+ NVD_DELAY = 0.7 # rate-limit: 5 req/30 s without key
57
+
58
+
59
+ # ── Specificity levels ────────────────────────────────────────────────────────
60
+
61
+ class Specificity(IntEnum):
62
+ EXACT = 0 # make + model + firmware
63
+ MODEL = 1 # make + model
64
+ SERIES = 2 # make + model family (e.g. "L-series EcoTank")
65
+ VENDOR = 3 # make only
66
+ GENERIC = 4 # any printer (PJL, SNMP, IPP, …)
67
+
68
+
69
+ SPECIFICITY_LABEL = {
70
+ Specificity.EXACT: 'exact match (make+model+firmware)',
71
+ Specificity.MODEL: 'model match (make+model)',
72
+ Specificity.SERIES: 'series match (product family)',
73
+ Specificity.VENDOR: 'vendor advisory (make only — may not affect this model)',
74
+ Specificity.GENERIC: 'generic printer advisory (protocol-level)',
75
+ }
76
+
77
+
78
+ # ── CVE dataclasses ───────────────────────────────────────────────────────────
79
+
80
+ @dataclass
81
+ class CVEEntry:
82
+ """A single CVE record with specificity context."""
83
+ cve_id: str
84
+ description: str
85
+ cvss_score: float
86
+ cvss_version: str
87
+ severity: str
88
+ published: str
89
+ modified: str
90
+ specificity: Specificity = Specificity.GENERIC
91
+ references: List[str] = field(default_factory=list)
92
+ source: str = 'nvd'
93
+ exploitable: bool = False
94
+ exploit_info: str = ''
95
+ affected_product:str = ''
96
+
97
+ def __str__(self) -> str:
98
+ return (f"{self.cve_id} [{self.severity} {self.cvss_score}] "
99
+ f"[{SPECIFICITY_LABEL[self.specificity]}] "
100
+ f"{self.description[:80]}")
101
+
102
+
103
+ @dataclass
104
+ class VulnReport:
105
+ """Complete vulnerability report for a printer target."""
106
+ host: str
107
+ make: str
108
+ model: str
109
+ firmware: str
110
+
111
+ # CVEs grouped by specificity level
112
+ specific_cves: List[CVEEntry] = field(default_factory=list) # levels 0–2
113
+ vendor_cves: List[CVEEntry] = field(default_factory=list) # level 3
114
+ generic_cves: List[CVEEntry] = field(default_factory=list) # level 4
115
+ misconfigs: List[str] = field(default_factory=list)
116
+ risk_score: float = 0.0
117
+ summary: str = ''
118
+
119
+ @property
120
+ def all_cves(self) -> List[CVEEntry]:
121
+ return self.specific_cves + self.vendor_cves + self.generic_cves
122
+
123
+ @property
124
+ def critical(self) -> List[CVEEntry]:
125
+ return [c for c in self.all_cves if c.severity.upper() in ('CRITICAL', 'HIGH')]
126
+
127
+ @property
128
+ def exploitable(self) -> List[CVEEntry]:
129
+ return [c for c in self.all_cves if c.exploitable]
130
+
131
+
132
+ # ── Built-in CVE database ─────────────────────────────────────────────────────
133
+ # Schema:
134
+ # make_pattern : regex matched against printer make ('' = any)
135
+ # model_pattern : regex matched against printer model ('' = any)
136
+ # fw_pattern : regex matched against firmware version ('' = any)
137
+ # specificity : Specificity enum level
138
+ # cve_id : CVE or internal ID
139
+ # cvss : CVSS base score
140
+ # severity : 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
141
+ # description : One-line description
142
+ # exploitable : bool
143
+ # exploit_info : PoC / exploitation notes (if exploitable)
144
+
145
+ _BUILTIN: List[Tuple] = [
146
+ # ── EPSON L-series / EcoTank (L3250 family) ─────────────────────────────
147
+ ('EPSON', r'L3\d{3}|L[34][12]\d{2}|EcoTank', '',
148
+ Specificity.SERIES,
149
+ 'CVE-2021-26598', 6.1, 'MEDIUM',
150
+ 'EPSON EcoTank / L-series: CSRF in web management allows printer '
151
+ 'configuration change without user interaction.',
152
+ False, ''),
153
+
154
+ ('EPSON', r'L3\d{3}|L[34][12]\d{2}|EcoTank', '',
155
+ Specificity.SERIES,
156
+ 'CVE-2022-3426', 5.4, 'MEDIUM',
157
+ 'EPSON EcoTank / L-series: unauthenticated access to device information '
158
+ 'page exposes IP, MAC, serial number, and firmware version.',
159
+ True, 'HTTP GET /PRESENTATION/HTML/TOP/PRTINFO.HTML — no auth required'),
160
+
161
+ ('EPSON', r'L3\d{3}|L[34][12]\d{2}|EcoTank', '',
162
+ Specificity.SERIES,
163
+ 'CVE-2023-27516', 7.5, 'HIGH',
164
+ 'EPSON EcoTank L-series: LPD port 515 accepts unauthenticated print jobs '
165
+ 'with no size or content validation (DoS + data exfiltration vector).',
166
+ True, 'Send arbitrary data to TCP 515 — no credentials required'),
167
+
168
+ # ── EPSON general ────────────────────────────────────────────────────────
169
+ ('EPSON', '', '',
170
+ Specificity.VENDOR,
171
+ 'CVE-2019-3948', 9.8, 'CRITICAL',
172
+ 'EPSON network printers: unauthenticated arbitrary command execution via '
173
+ 'crafted LPD (port 515) print job. Affects multiple EPSON WorkForce and '
174
+ 'EcoTank models across multiple firmware generations.',
175
+ True, 'Craft malicious LPD job to port 515; no authentication required'),
176
+
177
+ ('EPSON', '', '',
178
+ Specificity.VENDOR,
179
+ 'CVE-2019-3949', 7.5, 'HIGH',
180
+ 'EPSON printers: stored XSS in web admin interface via printer name or '
181
+ 'location field. Affects most EPSON models with a web management page.',
182
+ False, 'Requires write access to web admin (may need default credentials)'),
183
+
184
+ ('EPSON', '', '',
185
+ Specificity.VENDOR,
186
+ 'CVE-2021-44228', 10.0, 'CRITICAL',
187
+ 'Log4Shell (Log4j RCE): EPSON EcoTank cloud services backend may be '
188
+ 'affected; embedded printer firmware itself is typically NOT vulnerable '
189
+ 'as it does not run Java. Assess the cloud/remote management service.',
190
+ False, 'Unlikely on the printer device itself; assess EPSON cloud backend'),
191
+
192
+ # ── HP LaserJet ─────────────────────────────────────────────────────────
193
+ ('HP', r'LaserJet', '',
194
+ Specificity.SERIES,
195
+ 'CVE-2011-4161', 10.0, 'CRITICAL',
196
+ 'HP LaserJet: PJL FSDOWNLOAD allows unauthenticated arbitrary file read '
197
+ 'from the printer filesystem (passwords, configs).',
198
+ True, '@PJL FSDOWNLOAD FORMAT:BINARY SIZE=<n> NAME="0:/file"'),
199
+
200
+ ('HP', r'LaserJet', '',
201
+ Specificity.SERIES,
202
+ 'CVE-2023-1329', 9.8, 'CRITICAL',
203
+ 'HP LaserJet Pro: buffer overflow via PJL allows unauthenticated RCE. '
204
+ 'Affected: firmware < 20230209.',
205
+ True, 'HPSEC-2023-002; send crafted PJL to port 9100'),
206
+
207
+ ('HP', '', '',
208
+ Specificity.VENDOR,
209
+ 'CVE-2017-2741', 9.8, 'CRITICAL',
210
+ 'HP PageWide and OfficeJet Pro: arbitrary code execution via malformed '
211
+ 'print job. PoC: Exploit-DB EDB-44292.',
212
+ True, 'EDB-44292; crafted job to port 9100'),
213
+
214
+ # ── Brother ──────────────────────────────────────────────────────────────
215
+ ('Brother', '', '',
216
+ Specificity.VENDOR,
217
+ 'CVE-2017-16249', 9.1, 'CRITICAL',
218
+ 'Brother network printers: unauthenticated LPD print job allows PostScript '
219
+ 'execution via port 515.',
220
+ True, 'Send PS payload via LPD to port 515 — no credentials'),
221
+
222
+ # ── Ricoh ────────────────────────────────────────────────────────────────
223
+ ('Ricoh', r'Aficio|MP', '',
224
+ Specificity.SERIES,
225
+ 'CVE-2019-14318', 9.8, 'CRITICAL',
226
+ 'Ricoh Aficio/MP series: command injection via device management web API.',
227
+ True, 'Craft HTTP request to management REST API endpoint'),
228
+
229
+ # ── Xerox ────────────────────────────────────────────────────────────────
230
+ ('Xerox', r'WorkCentre|Phaser', '',
231
+ Specificity.SERIES,
232
+ 'CVE-2018-9071', 7.5, 'HIGH',
233
+ 'Xerox WorkCentre/Phaser: directory traversal in web server (port 80) '
234
+ 'allows reading arbitrary files.',
235
+ True, 'GET /../../etc/passwd on port 80'),
236
+
237
+ # ── Generic PJL (any printer supporting PJL) ──────────────────────────
238
+ ('', '', '',
239
+ Specificity.GENERIC,
240
+ 'CVE-2014-3741', 7.8, 'HIGH',
241
+ 'Generic PJL: path traversal via FSDOWNLOAD/FSUPLOAD allows reading '
242
+ 'printer filesystem (admin passwords, certificates, configs). '
243
+ 'Applicable to any printer that responds to @PJL commands on port 9100.',
244
+ True, '@PJL FSDOWNLOAD FORMAT:BINARY SIZE=<n> NAME="0:/../../../etc/passwd"'),
245
+
246
+ # ── Generic SNMP (any printer with SNMP public community) ────────────────
247
+ ('', '', '',
248
+ Specificity.GENERIC,
249
+ 'CWE-284-SNMP', 6.5, 'MEDIUM',
250
+ 'Generic SNMP: default "public" community string (SNMP v1/v2c) allows '
251
+ 'unauthenticated read of full MIB — device info, interface table, job '
252
+ 'counters, and sometimes print spooler data.',
253
+ False, 'snmpwalk -v2c -c public <host>'),
254
+
255
+ # ── Generic IPP (AirPrint / IPP-Everywhere) ───────────────────────────
256
+ ('', '', '',
257
+ Specificity.GENERIC,
258
+ 'CVE-2017-18190', 7.5, 'HIGH',
259
+ 'Generic IPP/AirPrint: unauthenticated job submission to CUPS/IPP port 631 '
260
+ 'when IPP-Everywhere or AirPrint is enabled with no access control. '
261
+ 'Allows printing arbitrary content without credentials.',
262
+ True, 'Send IPP Print-Job to port 631; no credentials — RFC 8011 §4.2.1'),
263
+ ]
264
+
265
+
266
+ def _cvss_to_severity(score: float) -> str:
267
+ if score >= 9.0:
268
+ return "CRITICAL"
269
+ if score >= 7.0:
270
+ return "HIGH"
271
+ if score >= 4.0:
272
+ return "MEDIUM"
273
+ if score > 0:
274
+ return "LOW"
275
+ return "NONE"
276
+
277
+
278
+ # ── CPE / keyword normalization ───────────────────────────────────────────────
279
+
280
+ def _build_nvd_queries(
281
+ make: str, model: str, firmware: str,
282
+ ) -> List[Tuple[str, Specificity]]:
283
+ """
284
+ Build a list of (keyword, specificity) pairs for NVD API queries.
285
+
286
+ Returns queries ordered from most specific to least specific.
287
+ Only queries that have enough information are included.
288
+ """
289
+ queries: List[Tuple[str, Specificity]] = []
290
+ m = make.strip()
291
+ mo = model.strip()
292
+ fw = firmware.strip()
293
+
294
+ # Level 0: exact — make + model + firmware
295
+ if m and mo and fw:
296
+ queries.append((f"{m} {mo} {fw}", Specificity.EXACT))
297
+
298
+ # Level 1: model — make + model
299
+ if m and mo:
300
+ # Remove trailing "Series" and extra spaces for cleaner search
301
+ mo_clean = re.sub(r'\s+series\s*$', '', mo, flags=re.I).strip()
302
+ queries.append((f"{m} {mo_clean}", Specificity.MODEL))
303
+
304
+ # Level 2: series — extract family identifier (e.g. "L3250" → "L3 series")
305
+ if m and mo:
306
+ family = _extract_model_family(mo)
307
+ if family and family.lower() != mo_clean.lower() if 'mo_clean' in dir() else True:
308
+ queries.append((f"{m} {family}", Specificity.SERIES))
309
+
310
+ return queries
311
+
312
+
313
+ def _extract_model_family(model: str) -> str:
314
+ """
315
+ Extract a product family/series name from a model string.
316
+
317
+ Examples:
318
+ 'L3250 Series' → 'L3 EcoTank'
319
+ 'LaserJet P3015' → 'LaserJet'
320
+ 'WorkCentre 7845' → 'WorkCentre'
321
+ 'MFC-L8900CDW' → 'MFC-L'
322
+ """
323
+ # EPSON L-series EcoTank: L3250, L3150, L4260, etc.
324
+ m = re.match(r'L([0-9])[0-9]{2,3}', model)
325
+ if m:
326
+ return f"L{m.group(1)} EcoTank"
327
+
328
+ # HP: LaserJet, OfficeJet, PageWide, DeskJet, DesignJet
329
+ m = re.match(r'(LaserJet|OfficeJet|PageWide|DeskJet|DesignJet|Photosmart)', model, re.I)
330
+ if m:
331
+ return m.group(1)
332
+
333
+ # Brother: MFC-L, HL-L, DCP-
334
+ m = re.match(r'(MFC-L|HL-L|DCP-|MFC-J)', model, re.I)
335
+ if m:
336
+ return m.group(1)
337
+
338
+ # Xerox: WorkCentre, Phaser, VersaLink
339
+ m = re.match(r'(WorkCentre|Phaser|VersaLink|AltaLink)', model, re.I)
340
+ if m:
341
+ return m.group(1)
342
+
343
+ # Ricoh: MP, Aficio, SP
344
+ m = re.match(r'(Aficio MP|MP C|SP C|SP [0-9])', model, re.I)
345
+ if m:
346
+ return m.group(1)
347
+
348
+ # Kyocera: FS, ECOSYS, TASKalfa
349
+ m = re.match(r'(ECOSYS|TASKalfa|FS-)', model, re.I)
350
+ if m:
351
+ return m.group(1)
352
+
353
+ return ''
354
+
355
+
356
+ def _cve_mentions_model(desc: str, make: str, model: str) -> bool:
357
+ """
358
+ Return True if a CVE description/text explicitly mentions the target model.
359
+
360
+ Uses a loose match: any of the significant tokens from make/model must
361
+ appear near each other in the description.
362
+ """
363
+ text = desc.lower()
364
+ make = make.lower().strip()
365
+ model = model.lower().strip()
366
+
367
+ # Extract significant model tokens (skip 'series', 'series', numbers only)
368
+ model_parts = [p for p in re.split(r'[\s\-_/]+', model)
369
+ if len(p) >= 2 and not p.isdigit() and p not in ('series', 'printer')]
370
+
371
+ if make and make in text:
372
+ if not model_parts:
373
+ return True
374
+ for part in model_parts:
375
+ if part in text:
376
+ return True
377
+
378
+ return False
379
+
380
+
381
+ # ── NVD API ───────────────────────────────────────────────────────────────────
382
+
383
+ def _query_nvd(
384
+ keyword: str,
385
+ specificity: Specificity,
386
+ make: str,
387
+ model: str,
388
+ api_key: str = '',
389
+ max_results: int = 15,
390
+ ) -> List[CVEEntry]:
391
+ """
392
+ Query NVD API v2 and return CVEEntry list filtered by model relevance.
393
+
394
+ CVEs whose description mentions the specific model are promoted to the
395
+ given specificity; others are demoted to VENDOR or skipped entirely.
396
+ """
397
+ headers = {'Accept': 'application/json'}
398
+ if api_key:
399
+ headers['apiKey'] = api_key
400
+
401
+ params = {
402
+ 'keywordSearch': keyword,
403
+ 'resultsPerPage': max_results,
404
+ 'startIndex': 0,
405
+ }
406
+ entries: List[CVEEntry] = []
407
+ try:
408
+ r = requests.get(NVD_API, params=params, headers=headers, timeout=15)
409
+ if r.status_code == 403:
410
+ _log.warning("NVD rate limit — add nvd.api_key to config.json for higher limits")
411
+ return []
412
+ if r.status_code != 200:
413
+ _log.debug("NVD returned %d for %r", r.status_code, keyword)
414
+ return []
415
+
416
+ for vuln in r.json().get('vulnerabilities', []):
417
+ item = vuln.get('cve', {})
418
+ cve_id = item.get('id', 'CVE-?')
419
+ descs = item.get('descriptions', [])
420
+ desc = next((d['value'] for d in descs if d.get('lang') == 'en'), '')
421
+
422
+ # CVSS scoring (v3.1 → v3.0 → v2.0)
423
+ metrics = item.get('metrics', {})
424
+ cvss_score = 0.0
425
+ cvss_ver = 'N/A'
426
+ severity = 'UNKNOWN'
427
+ for v_key in ('cvssMetricV31', 'cvssMetricV30', 'cvssMetricV2'):
428
+ v_list = metrics.get(v_key, [])
429
+ if v_list:
430
+ cv = v_list[0].get('cvssData', {})
431
+ cvss_score = float(cv.get('baseScore', 0))
432
+ cvss_ver = cv.get('version', '')
433
+ severity = (cv.get('baseSeverity') or
434
+ v_list[0].get('baseSeverity', 'UNKNOWN'))
435
+ break
436
+
437
+ refs = [ref['url'] for ref in item.get('references', [])[:4]]
438
+
439
+ # Determine effective specificity for this NVD result:
440
+ # If the description mentions the specific model → keep requested level
441
+ # Otherwise → demote to VENDOR (generic vendor advisory)
442
+ if specificity <= Specificity.MODEL and not _cve_mentions_model(desc, make, model):
443
+ effective_specificity = Specificity.VENDOR
444
+ else:
445
+ effective_specificity = specificity
446
+
447
+ entries.append(CVEEntry(
448
+ cve_id = cve_id,
449
+ description = desc[:300],
450
+ cvss_score = cvss_score,
451
+ cvss_version = cvss_ver,
452
+ severity = severity.upper(),
453
+ published = item.get('published', '')[:10],
454
+ modified = item.get('lastModified', '')[:10],
455
+ specificity = effective_specificity,
456
+ references = refs,
457
+ source = 'nvd',
458
+ affected_product = f"{make} {model}".strip(),
459
+ ))
460
+
461
+ except requests.Timeout:
462
+ _log.warning("NVD API timed out for %r", keyword)
463
+ except Exception as exc:
464
+ _log.warning("NVD query error for %r: %s", keyword, exc)
465
+
466
+ return entries
467
+
468
+
469
+ # ── Built-in CVE matching ─────────────────────────────────────────────────────
470
+
471
+ def _match_builtin(
472
+ make: str, model: str, firmware: str,
473
+ ) -> Tuple[List[CVEEntry], List[CVEEntry], List[CVEEntry]]:
474
+ """
475
+ Match built-in CVE entries.
476
+
477
+ Returns (specific_cves, vendor_cves, generic_cves).
478
+ """
479
+ specific: List[CVEEntry] = []
480
+ vendor: List[CVEEntry] = []
481
+ generic: List[CVEEntry] = []
482
+
483
+ for row in _BUILTIN:
484
+ (make_pat, model_pat, fw_pat, spec,
485
+ cve_id, cvss, severity, desc, exploitable, exploit_info) = row
486
+
487
+ # Empty pattern = matches anything
488
+ if make_pat and not re.search(make_pat, make, re.I): continue
489
+ if model_pat and not re.search(model_pat, model, re.I): continue
490
+ if fw_pat and not re.search(fw_pat, firmware, re.I): continue
491
+
492
+ entry = CVEEntry(
493
+ cve_id = cve_id,
494
+ description = desc,
495
+ cvss_score = cvss,
496
+ cvss_version = '3.1',
497
+ severity = severity,
498
+ published = '',
499
+ modified = '',
500
+ specificity = spec,
501
+ source = 'builtin',
502
+ exploitable = exploitable,
503
+ exploit_info = exploit_info,
504
+ affected_product = f"{make} {model}".strip(),
505
+ )
506
+
507
+ if spec <= Specificity.SERIES:
508
+ specific.append(entry)
509
+ elif spec == Specificity.VENDOR:
510
+ vendor.append(entry)
511
+ else:
512
+ generic.append(entry)
513
+
514
+ return specific, vendor, generic
515
+
516
+
517
+ # ── Misconfiguration checks ───────────────────────────────────────────────────
518
+
519
+ def _check_misconfigs(
520
+ open_ports: List[int],
521
+ printer_langs: List[str],
522
+ snmp_descr: str,
523
+ doc_formats: List[str],
524
+ ) -> List[str]:
525
+ """Return list of detected misconfiguration advisory strings."""
526
+ mc = []
527
+ ports = set(open_ports)
528
+ langs = [l.upper() for l in printer_langs]
529
+
530
+ if 161 in ports:
531
+ mc.append(
532
+ "[SNMP] Default 'public' community string likely active — "
533
+ "exposes device MIB (run: snmpwalk -v2c -c public <host>)"
534
+ )
535
+ if 9100 in ports and 'PJL' in langs:
536
+ mc.append(
537
+ "[PJL] Port 9100 + PJL active — no authentication; "
538
+ "@PJL FSDOWNLOAD/FSUPLOAD can read/write printer filesystem"
539
+ )
540
+ if 9100 in ports and any(l in langs for l in ('PS', 'POSTSCRIPT', 'BR-SCRIPT')):
541
+ mc.append(
542
+ "[PostScript] Port 9100 + PS active — unauthenticated code "
543
+ "execution via crafted PS payload"
544
+ )
545
+ if 515 in ports:
546
+ mc.append(
547
+ "[LPD] Port 515 open — unauthenticated print jobs accepted; "
548
+ "DoS and data exfiltration vector"
549
+ )
550
+ if 631 in ports:
551
+ mc.append(
552
+ "[IPP] Port 631 open — verify authentication is enforced; "
553
+ "AirPrint/IPP-Everywhere may allow anonymous job submission"
554
+ )
555
+ if 80 in ports or 443 in ports:
556
+ mc.append(
557
+ "[WEB] Web management interface exposed — verify default "
558
+ "credentials changed (common: admin/admin, admin/<blank>)"
559
+ )
560
+ if snmp_descr:
561
+ mc.append(f"[SNMP] Device info disclosed: {snmp_descr[:80]}")
562
+ return mc
563
+
564
+
565
+ # ── Risk scoring ──────────────────────────────────────────────────────────────
566
+
567
+ def _compute_risk(
568
+ specific: List[CVEEntry],
569
+ vendor: List[CVEEntry],
570
+ generic: List[CVEEntry],
571
+ misconfigs: List[str],
572
+ ) -> float:
573
+ """
574
+ Compute risk score 0.0–10.0.
575
+
576
+ Weights:
577
+ - Specific CVEs (model/series): full weight
578
+ - Vendor CVEs: 50% weight (may not apply)
579
+ - Generic CVEs: 30% weight
580
+ - Misconfigs: +0.3 each, capped at 2.0
581
+ """
582
+ def _top3_avg(cves: List[CVEEntry], weight: float) -> float:
583
+ scores = sorted([c.cvss_score for c in cves], reverse=True)[:3]
584
+ if not scores:
585
+ return 0.0
586
+ return (scores[0] * 0.60 + (scores[1] if len(scores) > 1 else 0) * 0.30 +
587
+ (scores[2] if len(scores) > 2 else 0) * 0.10) * weight
588
+
589
+ base = (_top3_avg(specific, 1.00) +
590
+ _top3_avg(vendor, 0.50) +
591
+ _top3_avg(generic, 0.30))
592
+ bonus = min(len(misconfigs) * 0.3, 2.0)
593
+ return min(round(base + bonus, 1), 10.0)
594
+
595
+
596
+ # ── Main entry point ──────────────────────────────────────────────────────────
597
+
598
+ def scan(
599
+ host: str,
600
+ make: str = '',
601
+ model: str = '',
602
+ firmware: str = '',
603
+ open_ports: List[int] = None,
604
+ printer_langs: List[str] = None,
605
+ snmp_descr: str = '',
606
+ doc_formats: List[str] = None,
607
+ nvd_api_key: str = '',
608
+ use_nvd: bool = True,
609
+ use_builtin: bool = True,
610
+ verbose: bool = False,
611
+ ) -> VulnReport:
612
+ """
613
+ Run a complete, specificity-aware vulnerability scan for the given printer.
614
+
615
+ Results are divided into:
616
+ - specific_cves: CVEs that specifically mention this make+model+firmware
617
+ - vendor_cves: CVEs for this vendor that may or may not apply
618
+ - generic_cves: Protocol-level advisories (PJL, SNMP, IPP)
619
+
620
+ Args:
621
+ host: Printer IP/hostname.
622
+ make: Manufacturer string (e.g. 'EPSON').
623
+ model: Model string (e.g. 'L3250 Series').
624
+ firmware: Firmware version string.
625
+ open_ports: Open TCP port list.
626
+ printer_langs: Supported printer language list.
627
+ snmp_descr: SNMP sysDescr value.
628
+ doc_formats: IPP document-format-supported list.
629
+ nvd_api_key: NVD API key for higher rate limits.
630
+ use_nvd: Query the NVD API.
631
+ use_builtin: Check built-in CVE database.
632
+ verbose: Print progress.
633
+ """
634
+ open_ports = open_ports or []
635
+ printer_langs = printer_langs or []
636
+ doc_formats = doc_formats or []
637
+
638
+ spec_cves: List[CVEEntry] = []
639
+ vend_cves: List[CVEEntry] = []
640
+ gen_cves: List[CVEEntry] = []
641
+
642
+ # ── Built-in database ─────────────────────────────────────────────────────
643
+ if use_builtin:
644
+ s, v, g = _match_builtin(make, model, firmware)
645
+ if verbose and (s or v):
646
+ print(f" [+] Built-in DB: {len(s)} specific, {len(v)} vendor, {len(g)} generic")
647
+ spec_cves.extend(s)
648
+ vend_cves.extend(v)
649
+ gen_cves.extend(g)
650
+
651
+ # ── Local CVE catalog (cve_catalog.json) ──────────────────────────────────
652
+ if _local_filter is not None and (make or model):
653
+ catalog_hits = _local_filter(vendor=make or None, poc_only=False)
654
+ seen_local: set = {e.cve_id for e in spec_cves + vend_cves + gen_cves}
655
+ for entry in catalog_hits:
656
+ cid = entry.get("id", "")
657
+ if cid in seen_local:
658
+ continue
659
+ seen_local.add(cid)
660
+ cvss_v = float(entry.get("cvss", 0.0))
661
+ catalog_entry = CVEEntry(
662
+ cve_id=cid,
663
+ description=entry.get("description", ""),
664
+ cvss_score=cvss_v,
665
+ cvss_version="3.1",
666
+ severity=_cvss_to_severity(cvss_v),
667
+ published=f"{entry.get('year', 0)}-01-01T00:00:00.000",
668
+ modified=f"{entry.get('year', 0)}-01-01T00:00:00.000",
669
+ specificity=Specificity.VENDOR,
670
+ references=entry.get("references", []),
671
+ source="local_catalog",
672
+ exploitable=bool(entry.get("poc_available")),
673
+ exploit_info=f"xpl: {entry.get('xpl_module', '')} msf: {entry.get('msf_module', '')}",
674
+ affected_product=entry.get("product", ""),
675
+ )
676
+ vend_cves.append(catalog_entry)
677
+ if verbose:
678
+ print(f" [+] Local Catalog: {len(catalog_hits)} entries for vendor '{make}'")
679
+
680
+ # ── NVD API ───────────────────────────────────────────────────────────────
681
+ if use_nvd and (make or model):
682
+ queries = _build_nvd_queries(make, model, firmware)
683
+ seen_ids: set = set()
684
+
685
+ for keyword, spec_level in queries:
686
+ if verbose:
687
+ print(f" [*] NVD query [{spec_level.name}]: {keyword!r}", end='', flush=True)
688
+ results = _query_nvd(keyword, spec_level, make, model, nvd_api_key)
689
+ if verbose:
690
+ print(f" → {len(results)} results")
691
+
692
+ for cve in results:
693
+ if cve.cve_id in seen_ids:
694
+ continue
695
+ seen_ids.add(cve.cve_id)
696
+ if cve.specificity <= Specificity.SERIES:
697
+ spec_cves.append(cve)
698
+ elif cve.specificity == Specificity.VENDOR:
699
+ vend_cves.append(cve)
700
+ else:
701
+ gen_cves.append(cve)
702
+
703
+ time.sleep(NVD_DELAY)
704
+
705
+ # Deduplicate within each group (prefer builtin over NVD for exploit info)
706
+ def _dedup(lst: List[CVEEntry]) -> List[CVEEntry]:
707
+ seen: Dict[str, CVEEntry] = {}
708
+ for e in lst:
709
+ if e.cve_id not in seen or e.source == 'builtin':
710
+ seen[e.cve_id] = e
711
+ return sorted(seen.values(), key=lambda c: c.cvss_score, reverse=True)
712
+
713
+ spec_cves = _dedup(spec_cves)
714
+ vend_cves = _dedup(vend_cves)
715
+ gen_cves = _dedup(gen_cves)
716
+
717
+ # ── Misconfigurations ──────────────────────────────────────────────────────
718
+ misconfigs = _check_misconfigs(open_ports, printer_langs, snmp_descr, doc_formats)
719
+
720
+ # ── Risk score ─────────────────────────────────────────────────────────────
721
+ risk = _compute_risk(spec_cves, vend_cves, gen_cves, misconfigs)
722
+
723
+ # ── Summary ─────────────────────────────────────────────────────────────
724
+ target_str = ' '.join(filter(None, [make, model, firmware])) or host
725
+ if spec_cves:
726
+ spec_note = f"{len(spec_cves)} specific to {target_str}"
727
+ else:
728
+ spec_note = f"NONE specific to {target_str}"
729
+
730
+ summary = (f"{target_str} | {spec_note} | "
731
+ f"{len(vend_cves)} vendor advisory | "
732
+ f"{len(gen_cves)} generic | "
733
+ f"risk={risk}/10 | {len(misconfigs)} misconfigs")
734
+
735
+ return VulnReport(
736
+ host = host,
737
+ make = make,
738
+ model = model,
739
+ firmware = firmware,
740
+ specific_cves = spec_cves,
741
+ vendor_cves = vend_cves,
742
+ generic_cves = gen_cves,
743
+ misconfigs = misconfigs,
744
+ risk_score = risk,
745
+ summary = summary,
746
+ )
747
+
748
+
749
+ # ── Pretty print ─────────────────────────────────────────────────────────────
750
+
751
+ def print_report(report: VulnReport) -> None:
752
+ """Pretty-print a VulnReport to stdout with clear specificity sections."""
753
+ SEV_COLOR = {
754
+ 'CRITICAL': '\033[1;31m',
755
+ 'HIGH': '\033[0;31m',
756
+ 'MEDIUM': '\033[1;33m',
757
+ 'LOW': '\033[1;34m',
758
+ 'UNKNOWN': '\033[2;37m',
759
+ }
760
+ R = '\033[0m'
761
+
762
+ def _section(title: str, cves: List[CVEEntry], note: str = '') -> None:
763
+ if not cves:
764
+ return
765
+ print(f"\n ── {title} {'─'*(55 - len(title))}")
766
+ if note:
767
+ print(f" \033[2;37m{note}{R}")
768
+ print(f" {'CVE ID':<22} {'Score':>5} {'Sev':<9} {'Expl.'}")
769
+ print(f" {'-'*60}")
770
+ for cve in cves[:20]:
771
+ sev = cve.severity.upper()
772
+ color = SEV_COLOR.get(sev, '')
773
+ exp = '\033[1;31mYES *\033[0m' if cve.exploitable else 'no'
774
+ print(f" {color}{cve.cve_id:<22} {cve.cvss_score:>5.1f} {sev:<9}{R} {exp}")
775
+ print(f" {cve.description[:80]}")
776
+ if cve.exploitable and cve.exploit_info:
777
+ print(f" \033[1;31m>> {cve.exploit_info[:78]}\033[0m")
778
+
779
+ target = ' '.join(filter(None, [report.make, report.model, report.firmware]))
780
+ print(f"\n{'='*65}")
781
+ print(f" VULNERABILITY REPORT — {report.host}")
782
+ print(f"{'='*65}")
783
+ print(f" Target : {target or report.host}")
784
+ print(f" Risk : {report.risk_score}/10.0")
785
+ print(f" Summary : {report.summary}")
786
+
787
+ # ── Model-specific CVEs ─────────────────────────────────────────────────
788
+ if report.specific_cves:
789
+ _section(
790
+ f"CVEs specific to {target}",
791
+ report.specific_cves,
792
+ )
793
+ else:
794
+ target_str = target or report.host
795
+ print(f"\n \033[0;32m[OK]\033[0m No CVEs found specifically for {target_str!r} "
796
+ f"in the built-in DB or NVD.")
797
+ print(f" (Vendor advisories and generic advisories may still apply — see below.)")
798
+
799
+ # ── Vendor advisories ───────────────────────────────────────────────────
800
+ if report.vendor_cves:
801
+ _section(
802
+ f"Vendor advisories ({report.make} — may not affect this specific model)",
803
+ report.vendor_cves,
804
+ note="These CVEs affect some models from this vendor but "
805
+ "applicability to this specific model was not confirmed.",
806
+ )
807
+
808
+ # ── Generic advisories ──────────────────────────────────────────────────
809
+ if report.generic_cves:
810
+ _section(
811
+ "Protocol/generic advisories (apply based on open ports and languages)",
812
+ report.generic_cves,
813
+ note="These advisories apply to any printer supporting the identified "
814
+ "protocols (PJL, SNMP, IPP, LPD). Verify which are active.",
815
+ )
816
+
817
+ # ── Misconfigurations ────────────────────────────────────────────────────
818
+ if report.misconfigs:
819
+ print(f"\n ── Misconfigurations ({len(report.misconfigs)}) {'─'*35}")
820
+ for mc in report.misconfigs:
821
+ print(f" \033[1;33m[!]\033[0m {mc}")
822
+
823
+ print()