s3db.js 13.6.0 → 14.0.2

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 (193) hide show
  1. package/README.md +139 -43
  2. package/dist/s3db.cjs +72425 -38970
  3. package/dist/s3db.cjs.map +1 -1
  4. package/dist/s3db.es.js +72177 -38764
  5. package/dist/s3db.es.js.map +1 -1
  6. package/mcp/lib/base-handler.js +157 -0
  7. package/mcp/lib/handlers/connection-handler.js +280 -0
  8. package/mcp/lib/handlers/query-handler.js +533 -0
  9. package/mcp/lib/handlers/resource-handler.js +428 -0
  10. package/mcp/lib/tool-registry.js +336 -0
  11. package/mcp/lib/tools/connection-tools.js +161 -0
  12. package/mcp/lib/tools/query-tools.js +267 -0
  13. package/mcp/lib/tools/resource-tools.js +404 -0
  14. package/package.json +94 -49
  15. package/src/clients/memory-client.class.js +346 -191
  16. package/src/clients/memory-storage.class.js +300 -84
  17. package/src/clients/s3-client.class.js +7 -6
  18. package/src/concerns/geo-encoding.js +19 -2
  19. package/src/concerns/ip.js +59 -9
  20. package/src/concerns/money.js +8 -1
  21. package/src/concerns/password-hashing.js +49 -8
  22. package/src/concerns/plugin-storage.js +186 -18
  23. package/src/concerns/storage-drivers/filesystem-driver.js +284 -0
  24. package/src/database.class.js +139 -29
  25. package/src/errors.js +332 -42
  26. package/src/plugins/api/auth/oidc-auth.js +66 -17
  27. package/src/plugins/api/auth/strategies/base-strategy.class.js +74 -0
  28. package/src/plugins/api/auth/strategies/factory.class.js +63 -0
  29. package/src/plugins/api/auth/strategies/global-strategy.class.js +44 -0
  30. package/src/plugins/api/auth/strategies/path-based-strategy.class.js +83 -0
  31. package/src/plugins/api/auth/strategies/path-rules-strategy.class.js +118 -0
  32. package/src/plugins/api/concerns/failban-manager.js +106 -57
  33. package/src/plugins/api/concerns/opengraph-helper.js +116 -0
  34. package/src/plugins/api/concerns/route-context.js +601 -0
  35. package/src/plugins/api/concerns/state-machine.js +288 -0
  36. package/src/plugins/api/index.js +180 -41
  37. package/src/plugins/api/routes/auth-routes.js +198 -30
  38. package/src/plugins/api/routes/resource-routes.js +19 -4
  39. package/src/plugins/api/server/health-manager.class.js +163 -0
  40. package/src/plugins/api/server/middleware-chain.class.js +310 -0
  41. package/src/plugins/api/server/router.class.js +472 -0
  42. package/src/plugins/api/server.js +280 -1303
  43. package/src/plugins/api/utils/custom-routes.js +17 -5
  44. package/src/plugins/api/utils/guards.js +76 -17
  45. package/src/plugins/api/utils/openapi-generator-cached.class.js +133 -0
  46. package/src/plugins/api/utils/openapi-generator.js +7 -6
  47. package/src/plugins/api/utils/template-engine.js +77 -3
  48. package/src/plugins/audit.plugin.js +30 -8
  49. package/src/plugins/backup.plugin.js +110 -14
  50. package/src/plugins/cache/cache.class.js +22 -5
  51. package/src/plugins/cache/filesystem-cache.class.js +116 -19
  52. package/src/plugins/cache/memory-cache.class.js +211 -57
  53. package/src/plugins/cache/multi-tier-cache.class.js +371 -0
  54. package/src/plugins/cache/partition-aware-filesystem-cache.class.js +168 -47
  55. package/src/plugins/cache/redis-cache.class.js +552 -0
  56. package/src/plugins/cache/s3-cache.class.js +17 -8
  57. package/src/plugins/cache.plugin.js +176 -61
  58. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +8 -1
  59. package/src/plugins/cloud-inventory/drivers/aws-driver.js +60 -29
  60. package/src/plugins/cloud-inventory/drivers/azure-driver.js +8 -1
  61. package/src/plugins/cloud-inventory/drivers/base-driver.js +16 -2
  62. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +8 -1
  63. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +8 -1
  64. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +8 -1
  65. package/src/plugins/cloud-inventory/drivers/linode-driver.js +8 -1
  66. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +8 -1
  67. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +8 -1
  68. package/src/plugins/cloud-inventory/index.js +29 -8
  69. package/src/plugins/cloud-inventory/registry.js +64 -42
  70. package/src/plugins/cloud-inventory.plugin.js +240 -138
  71. package/src/plugins/concerns/plugin-dependencies.js +54 -0
  72. package/src/plugins/concerns/resource-names.js +100 -0
  73. package/src/plugins/consumers/index.js +10 -2
  74. package/src/plugins/consumers/sqs-consumer.js +12 -2
  75. package/src/plugins/cookie-farm-suite.plugin.js +278 -0
  76. package/src/plugins/cookie-farm.errors.js +73 -0
  77. package/src/plugins/cookie-farm.plugin.js +869 -0
  78. package/src/plugins/costs.plugin.js +7 -1
  79. package/src/plugins/eventual-consistency/analytics.js +94 -19
  80. package/src/plugins/eventual-consistency/config.js +15 -7
  81. package/src/plugins/eventual-consistency/consolidation.js +29 -11
  82. package/src/plugins/eventual-consistency/garbage-collection.js +3 -1
  83. package/src/plugins/eventual-consistency/helpers.js +39 -14
  84. package/src/plugins/eventual-consistency/install.js +21 -2
  85. package/src/plugins/eventual-consistency/utils.js +32 -10
  86. package/src/plugins/fulltext.plugin.js +38 -11
  87. package/src/plugins/geo.plugin.js +61 -9
  88. package/src/plugins/identity/concerns/config.js +61 -0
  89. package/src/plugins/identity/concerns/mfa-manager.js +15 -2
  90. package/src/plugins/identity/concerns/rate-limit.js +124 -0
  91. package/src/plugins/identity/concerns/resource-schemas.js +9 -1
  92. package/src/plugins/identity/concerns/token-generator.js +29 -4
  93. package/src/plugins/identity/drivers/auth-driver.interface.js +76 -0
  94. package/src/plugins/identity/drivers/client-credentials-driver.js +127 -0
  95. package/src/plugins/identity/drivers/index.js +18 -0
  96. package/src/plugins/identity/drivers/password-driver.js +122 -0
  97. package/src/plugins/identity/email-service.js +17 -2
  98. package/src/plugins/identity/index.js +413 -69
  99. package/src/plugins/identity/oauth2-server.js +413 -30
  100. package/src/plugins/identity/oidc-discovery.js +16 -8
  101. package/src/plugins/identity/rsa-keys.js +115 -35
  102. package/src/plugins/identity/server.js +166 -45
  103. package/src/plugins/identity/session-manager.js +53 -7
  104. package/src/plugins/identity/ui/pages/mfa-verification.js +17 -15
  105. package/src/plugins/identity/ui/routes.js +363 -255
  106. package/src/plugins/importer/index.js +153 -20
  107. package/src/plugins/index.js +9 -2
  108. package/src/plugins/kubernetes-inventory/index.js +6 -0
  109. package/src/plugins/kubernetes-inventory/k8s-driver.js +867 -0
  110. package/src/plugins/kubernetes-inventory/resource-types.js +274 -0
  111. package/src/plugins/kubernetes-inventory.plugin.js +980 -0
  112. package/src/plugins/metrics.plugin.js +64 -16
  113. package/src/plugins/ml/base-model.class.js +25 -15
  114. package/src/plugins/ml/regression-model.class.js +1 -1
  115. package/src/plugins/ml.errors.js +57 -25
  116. package/src/plugins/ml.plugin.js +28 -4
  117. package/src/plugins/namespace.js +210 -0
  118. package/src/plugins/plugin.class.js +180 -8
  119. package/src/plugins/puppeteer/console-monitor.js +729 -0
  120. package/src/plugins/puppeteer/cookie-manager.js +492 -0
  121. package/src/plugins/puppeteer/network-monitor.js +816 -0
  122. package/src/plugins/puppeteer/performance-manager.js +746 -0
  123. package/src/plugins/puppeteer/proxy-manager.js +478 -0
  124. package/src/plugins/puppeteer/stealth-manager.js +556 -0
  125. package/src/plugins/puppeteer.errors.js +81 -0
  126. package/src/plugins/puppeteer.plugin.js +1327 -0
  127. package/src/plugins/queue-consumer.plugin.js +69 -14
  128. package/src/plugins/recon/behaviors/uptime-behavior.js +691 -0
  129. package/src/plugins/recon/concerns/command-runner.js +148 -0
  130. package/src/plugins/recon/concerns/diff-detector.js +372 -0
  131. package/src/plugins/recon/concerns/fingerprint-builder.js +307 -0
  132. package/src/plugins/recon/concerns/process-manager.js +338 -0
  133. package/src/plugins/recon/concerns/report-generator.js +478 -0
  134. package/src/plugins/recon/concerns/security-analyzer.js +571 -0
  135. package/src/plugins/recon/concerns/target-normalizer.js +68 -0
  136. package/src/plugins/recon/config/defaults.js +321 -0
  137. package/src/plugins/recon/config/resources.js +370 -0
  138. package/src/plugins/recon/index.js +778 -0
  139. package/src/plugins/recon/managers/dependency-manager.js +174 -0
  140. package/src/plugins/recon/managers/scheduler-manager.js +179 -0
  141. package/src/plugins/recon/managers/storage-manager.js +745 -0
  142. package/src/plugins/recon/managers/target-manager.js +274 -0
  143. package/src/plugins/recon/stages/asn-stage.js +314 -0
  144. package/src/plugins/recon/stages/certificate-stage.js +84 -0
  145. package/src/plugins/recon/stages/dns-stage.js +107 -0
  146. package/src/plugins/recon/stages/dnsdumpster-stage.js +362 -0
  147. package/src/plugins/recon/stages/fingerprint-stage.js +71 -0
  148. package/src/plugins/recon/stages/google-dorks-stage.js +440 -0
  149. package/src/plugins/recon/stages/http-stage.js +89 -0
  150. package/src/plugins/recon/stages/latency-stage.js +148 -0
  151. package/src/plugins/recon/stages/massdns-stage.js +302 -0
  152. package/src/plugins/recon/stages/osint-stage.js +1373 -0
  153. package/src/plugins/recon/stages/ports-stage.js +169 -0
  154. package/src/plugins/recon/stages/screenshot-stage.js +94 -0
  155. package/src/plugins/recon/stages/secrets-stage.js +514 -0
  156. package/src/plugins/recon/stages/subdomains-stage.js +295 -0
  157. package/src/plugins/recon/stages/tls-audit-stage.js +78 -0
  158. package/src/plugins/recon/stages/vulnerability-stage.js +78 -0
  159. package/src/plugins/recon/stages/web-discovery-stage.js +113 -0
  160. package/src/plugins/recon/stages/whois-stage.js +349 -0
  161. package/src/plugins/recon.plugin.js +75 -0
  162. package/src/plugins/recon.plugin.js.backup +2635 -0
  163. package/src/plugins/relation.errors.js +87 -14
  164. package/src/plugins/replicator.plugin.js +514 -137
  165. package/src/plugins/replicators/base-replicator.class.js +89 -1
  166. package/src/plugins/replicators/bigquery-replicator.class.js +66 -22
  167. package/src/plugins/replicators/dynamodb-replicator.class.js +22 -15
  168. package/src/plugins/replicators/mongodb-replicator.class.js +22 -15
  169. package/src/plugins/replicators/mysql-replicator.class.js +52 -17
  170. package/src/plugins/replicators/planetscale-replicator.class.js +30 -4
  171. package/src/plugins/replicators/postgres-replicator.class.js +62 -27
  172. package/src/plugins/replicators/s3db-replicator.class.js +25 -18
  173. package/src/plugins/replicators/schema-sync.helper.js +3 -3
  174. package/src/plugins/replicators/sqs-replicator.class.js +8 -2
  175. package/src/plugins/replicators/turso-replicator.class.js +23 -3
  176. package/src/plugins/replicators/webhook-replicator.class.js +42 -4
  177. package/src/plugins/s3-queue.plugin.js +464 -65
  178. package/src/plugins/scheduler.plugin.js +20 -6
  179. package/src/plugins/state-machine.plugin.js +40 -9
  180. package/src/plugins/tfstate/README.md +126 -126
  181. package/src/plugins/tfstate/base-driver.js +28 -4
  182. package/src/plugins/tfstate/errors.js +65 -10
  183. package/src/plugins/tfstate/filesystem-driver.js +52 -8
  184. package/src/plugins/tfstate/index.js +163 -90
  185. package/src/plugins/tfstate/s3-driver.js +64 -6
  186. package/src/plugins/ttl.plugin.js +72 -17
  187. package/src/plugins/vector/distances.js +18 -12
  188. package/src/plugins/vector/kmeans.js +26 -4
  189. package/src/resource.class.js +115 -19
  190. package/src/testing/factory.class.js +20 -3
  191. package/src/testing/seeder.class.js +7 -1
  192. package/src/clients/memory-client.md +0 -917
  193. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +0 -449
@@ -0,0 +1,2635 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import dns from 'node:dns/promises';
7
+ import tls from 'node:tls';
8
+ import { promisify } from 'node:util';
9
+ import { URL } from 'node:url';
10
+ import { PromisePool } from '@supercharge/promise-pool';
11
+
12
+ import { Plugin } from './plugin.class.js';
13
+ import { resolveResourceName } from './concerns/resource-names.js';
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ class CommandRunner {
18
+ constructor(options = {}) {
19
+ this.execFile = options.execFile || execFileAsync;
20
+ this.availabilityCache = new Map();
21
+ }
22
+
23
+ async isAvailable(command, overridePath) {
24
+ if (overridePath) {
25
+ return true;
26
+ }
27
+
28
+ if (this.availabilityCache.has(command)) {
29
+ return this.availabilityCache.get(command);
30
+ }
31
+
32
+ try {
33
+ await this.execFile('which', [command], { timeout: 1500 });
34
+ this.availabilityCache.set(command, true);
35
+ return true;
36
+ } catch (error) {
37
+ if (error?.code === 'ENOENT') {
38
+ // command binary not found, mark skip
39
+ this.availabilityCache.set(command, false);
40
+ return false;
41
+ }
42
+ this.availabilityCache.set(command, false);
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async run(command, args = [], options = {}) {
48
+ const resolvedCommand = options?.path || command;
49
+
50
+ if (!(await this.isAvailable(command, options?.path))) {
51
+ const error = new Error(`Command "${command}" is not available on this system`);
52
+ error.code = 'ENOENT';
53
+ return { ok: false, error, stdout: '', stderr: '' };
54
+ }
55
+
56
+ try {
57
+ const result = await this.execFile(resolvedCommand, args, {
58
+ timeout: options.timeout ?? 10000,
59
+ maxBuffer: options.maxBuffer ?? 2 * 1024 * 1024
60
+ });
61
+ return {
62
+ ok: true,
63
+ stdout: result.stdout?.toString() ?? '',
64
+ stderr: result.stderr?.toString() ?? ''
65
+ };
66
+ } catch (error) {
67
+ return {
68
+ ok: false,
69
+ error,
70
+ stdout: error?.stdout?.toString() ?? '',
71
+ stderr: error?.stderr?.toString() ?? ''
72
+ };
73
+ }
74
+ }
75
+ }
76
+
77
+ const DEFAULT_FEATURES = {
78
+ dns: true,
79
+ certificate: true,
80
+ http: {
81
+ curl: true
82
+ },
83
+ latency: {
84
+ ping: true,
85
+ traceroute: true
86
+ },
87
+ subdomains: {
88
+ amass: true,
89
+ subfinder: true,
90
+ assetfinder: false,
91
+ crtsh: true
92
+ },
93
+ ports: {
94
+ nmap: true,
95
+ masscan: false
96
+ },
97
+ web: {
98
+ ffuf: false,
99
+ feroxbuster: false,
100
+ gobuster: false,
101
+ wordlist: null,
102
+ threads: 50
103
+ },
104
+ vulnerability: {
105
+ nikto: false,
106
+ wpscan: false,
107
+ droopescan: false
108
+ },
109
+ tlsAudit: {
110
+ sslyze: false,
111
+ testssl: false,
112
+ openssl: true
113
+ },
114
+ fingerprint: {
115
+ whatweb: false
116
+ },
117
+ screenshots: {
118
+ aquatone: false,
119
+ eyewitness: false
120
+ },
121
+ osint: {
122
+ theHarvester: false,
123
+ reconNg: false
124
+ }
125
+ };
126
+
127
+ const BEHAVIOR_PRESETS = {
128
+ passive: {
129
+ features: {
130
+ dns: true,
131
+ certificate: false,
132
+ http: { curl: false },
133
+ latency: { ping: false, traceroute: false },
134
+ subdomains: { amass: false, subfinder: false, assetfinder: false, crtsh: true },
135
+ ports: { nmap: false, masscan: false },
136
+ web: { ffuf: false, feroxbuster: false, gobuster: false },
137
+ vulnerability: { nikto: false, wpscan: false, droopescan: false },
138
+ tlsAudit: { openssl: false, sslyze: false, testssl: false },
139
+ fingerprint: { whatweb: false },
140
+ screenshots: { aquatone: false, eyewitness: false },
141
+ osint: { theHarvester: true, reconNg: false }
142
+ },
143
+ concurrency: 2,
144
+ ping: { count: 3, timeout: 5000 },
145
+ curl: { timeout: 10000 },
146
+ nmap: { topPorts: 0 },
147
+ rateLimit: { enabled: false, delayBetweenStages: 0 }
148
+ },
149
+ stealth: {
150
+ features: {
151
+ dns: true,
152
+ certificate: true,
153
+ http: { curl: true },
154
+ latency: { ping: true, traceroute: false },
155
+ subdomains: { amass: false, subfinder: true, assetfinder: false, crtsh: true },
156
+ ports: { nmap: true, masscan: false },
157
+ web: { ffuf: false, feroxbuster: false, gobuster: false },
158
+ vulnerability: { nikto: false, wpscan: false, droopescan: false },
159
+ tlsAudit: { openssl: true, sslyze: false, testssl: false },
160
+ fingerprint: { whatweb: false },
161
+ screenshots: { aquatone: false, eyewitness: false },
162
+ osint: { theHarvester: false, reconNg: false }
163
+ },
164
+ concurrency: 1,
165
+ ping: { count: 3, timeout: 10000 },
166
+ traceroute: { cycles: 3, timeout: 15000 },
167
+ curl: {
168
+ timeout: 15000,
169
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
170
+ },
171
+ nmap: { topPorts: 10, extraArgs: ['-T2', '--max-retries', '1'] },
172
+ rateLimit: { enabled: true, requestsPerMinute: 10, delayBetweenStages: 5000 }
173
+ },
174
+ aggressive: {
175
+ features: {
176
+ dns: true,
177
+ certificate: true,
178
+ http: { curl: true },
179
+ latency: { ping: true, traceroute: true },
180
+ subdomains: { amass: true, subfinder: true, assetfinder: true, crtsh: true },
181
+ ports: { nmap: true, masscan: true },
182
+ web: { ffuf: true, feroxbuster: true, gobuster: true, threads: 100 },
183
+ vulnerability: { nikto: true, wpscan: true, droopescan: true },
184
+ tlsAudit: { openssl: true, sslyze: true, testssl: true },
185
+ fingerprint: { whatweb: true },
186
+ screenshots: { aquatone: true, eyewitness: false },
187
+ osint: { theHarvester: true, reconNg: false }
188
+ },
189
+ concurrency: 8,
190
+ ping: { count: 4, timeout: 5000 },
191
+ traceroute: { cycles: 3, timeout: 10000 },
192
+ curl: { timeout: 8000 },
193
+ nmap: { topPorts: 100, extraArgs: ['-T4', '-sV', '--version-intensity', '5'] },
194
+ masscan: { ports: '1-65535', rate: 5000 },
195
+ rateLimit: { enabled: false, delayBetweenStages: 0 }
196
+ }
197
+ };
198
+
199
+ export class ReconPlugin extends Plugin {
200
+ constructor(options = {}) {
201
+ super(options);
202
+ const {
203
+ behavior = null,
204
+ behaviorOverrides = {},
205
+ tools,
206
+ concurrency,
207
+ ping = {},
208
+ traceroute = {},
209
+ curl = {},
210
+ nmap = {},
211
+ masscan = {},
212
+ commandRunner = null,
213
+ features = {},
214
+ storage = {},
215
+ schedule = {},
216
+ resources: resourceConfig = {},
217
+ targets = [],
218
+ rateLimit = {}
219
+ } = options;
220
+
221
+ const behaviorPreset = this._resolveBehaviorPreset(behavior, behaviorOverrides);
222
+
223
+ this.config = {
224
+ behavior: behavior || 'default',
225
+ defaultTools: tools || behaviorPreset.tools || ['dns', 'certificate', 'ping', 'traceroute', 'curl', 'ports', 'subdomains'],
226
+ concurrency: concurrency ?? behaviorPreset.concurrency ?? 4,
227
+ ping: {
228
+ count: ping.count ?? behaviorPreset.ping?.count ?? 4,
229
+ timeout: ping.timeout ?? behaviorPreset.ping?.timeout ?? 7000
230
+ },
231
+ traceroute: {
232
+ cycles: traceroute.cycles ?? behaviorPreset.traceroute?.cycles ?? 4,
233
+ timeout: traceroute.timeout ?? behaviorPreset.traceroute?.timeout ?? 12000
234
+ },
235
+ curl: {
236
+ timeout: curl.timeout ?? behaviorPreset.curl?.timeout ?? 8000,
237
+ userAgent:
238
+ curl.userAgent ??
239
+ behaviorPreset.curl?.userAgent ??
240
+ 'Mozilla/5.0 (compatible; s3db-recon/1.0; +https://github.com/forattini-dev/s3db.js)'
241
+ },
242
+ nmap: {
243
+ topPorts: nmap.topPorts ?? behaviorPreset.nmap?.topPorts ?? 10,
244
+ extraArgs: nmap.extraArgs ?? behaviorPreset.nmap?.extraArgs ?? []
245
+ },
246
+ masscan: {
247
+ ports: masscan.ports ?? behaviorPreset.masscan?.ports ?? '1-1000',
248
+ rate: masscan.rate ?? behaviorPreset.masscan?.rate ?? 1000,
249
+ timeout: masscan.timeout ?? behaviorPreset.masscan?.timeout ?? 30000
250
+ },
251
+ features: this._mergeFeatures(behaviorPreset.features || DEFAULT_FEATURES, features),
252
+ storage: {
253
+ persist: storage.persist !== false,
254
+ persistRawOutput: storage.persistRawOutput ?? true,
255
+ historyLimit: storage.historyLimit ?? 20
256
+ },
257
+ schedule: {
258
+ enabled: schedule.enabled ?? false,
259
+ cron: schedule.cron ?? null,
260
+ runOnStart: schedule.runOnStart ?? false
261
+ },
262
+ resources: {
263
+ persist: resourceConfig.persist !== false,
264
+ autoCreate: resourceConfig.autoCreate !== false
265
+ },
266
+ targets: Array.isArray(targets) ? targets : [],
267
+ rateLimit: {
268
+ enabled: rateLimit.enabled ?? behaviorPreset.rateLimit?.enabled ?? false,
269
+ requestsPerMinute: rateLimit.requestsPerMinute ?? behaviorPreset.rateLimit?.requestsPerMinute ?? 60,
270
+ delayBetweenStages: rateLimit.delayBetweenStages ?? behaviorPreset.rateLimit?.delayBetweenStages ?? 0
271
+ }
272
+ };
273
+
274
+ this.commandRunner = commandRunner || new CommandRunner();
275
+ this._cronJob = null;
276
+ this._cronModule = null;
277
+ this._resourceCache = new Map();
278
+ const resourceNamesOption = options.resourceNames || {};
279
+ this._resourceDescriptors = {
280
+ hosts: { defaultName: 'plg_recon_hosts', override: resourceNamesOption.hosts },
281
+ reports: { defaultName: 'plg_recon_reports', override: resourceNamesOption.reports },
282
+ diffs: { defaultName: 'plg_recon_diffs', override: resourceNamesOption.diffs },
283
+ stages: { defaultName: 'plg_recon_stage_results', override: resourceNamesOption.stages },
284
+ subdomains: { defaultName: 'plg_recon_subdomains', override: resourceNamesOption.subdomains },
285
+ paths: { defaultName: 'plg_recon_paths', override: resourceNamesOption.paths }
286
+ };
287
+ this._resourceNames = {};
288
+ this._refreshResourceNames();
289
+ }
290
+
291
+ async onInstall() {
292
+ if (this.database && this.config.resources.persist && this.config.resources.autoCreate) {
293
+ await this._ensureResources();
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ async onStart() {
299
+ await this._startScheduler();
300
+ if (this.config.schedule.enabled && this.config.schedule.runOnStart) {
301
+ await this._triggerScheduledSweep('startup');
302
+ }
303
+ }
304
+
305
+ async onStop() {
306
+ if (this._cronJob) {
307
+ this._cronJob.stop();
308
+ this._cronJob = null;
309
+ }
310
+ this._resourceCache.clear();
311
+ }
312
+
313
+ async runDiagnostics(target, options = {}) {
314
+ const normalizedTarget = this._normalizeTarget(target);
315
+ const features = this._mergeFeatures(this.config.features, options.features || {});
316
+ const stagePlan = this._resolveStagePlan(normalizedTarget, features, options.tools);
317
+ const results = {};
318
+ const stageErrors = {};
319
+ const startedAt = new Date().toISOString();
320
+
321
+ const pool = await PromisePool.withConcurrency(options.concurrency ?? this.config.concurrency)
322
+ .for(stagePlan)
323
+ .process(async (stage) => {
324
+ if (!stage.enabled) {
325
+ results[stage.name] = { status: 'disabled' };
326
+ return;
327
+ }
328
+
329
+ await this._applyRateLimit(stage.name);
330
+
331
+ try {
332
+ const output = await stage.execute();
333
+ results[stage.name] = output;
334
+ } catch (error) {
335
+ stageErrors[stage.name] = error;
336
+ results[stage.name] = {
337
+ status: 'error',
338
+ message: error?.message || 'Stage execution failed'
339
+ };
340
+ }
341
+ });
342
+
343
+ if (pool.errors.length > 0) {
344
+ this.emit('diagnostics:error', {
345
+ target: normalizedTarget.host,
346
+ errors: pool.errors.map(({ item, error }) => ({ stage: item?.name || item, message: error.message }))
347
+ });
348
+ }
349
+
350
+ const fingerprint = this._buildFingerprint(normalizedTarget, results);
351
+ const endedAt = new Date().toISOString();
352
+ const status = Object.values(stageErrors).length === 0 ? 'ok' : 'partial';
353
+
354
+ const report = {
355
+ target: normalizedTarget,
356
+ startedAt,
357
+ endedAt,
358
+ status,
359
+ results,
360
+ stages: results,
361
+ fingerprint,
362
+ toolsAttempted: stagePlan.filter(stage => stage.enabled).map(stage => stage.name)
363
+ };
364
+
365
+ const persist = options.persist ?? this.config.storage.persist;
366
+ if (persist) {
367
+ await this._persistReport(normalizedTarget, report);
368
+ }
369
+
370
+ if (this.database && this.config.resources.persist) {
371
+ await this._persistToResources(report);
372
+ }
373
+
374
+ return report;
375
+ }
376
+
377
+ _resolveStagePlan(normalizedTarget, features, requestedTools) {
378
+ const requestedSet = requestedTools
379
+ ? new Set(requestedTools.map((tool) => this._normalizeToolName(tool)))
380
+ : null;
381
+ const plan = [];
382
+ const include = (name, enabled, execute) => {
383
+ const allowed = requestedSet ? requestedSet.has(name) : true;
384
+ plan.push({ name, enabled: !!enabled && allowed, execute });
385
+ };
386
+
387
+ include('dns', features.dns !== false, () => this._gatherDns(normalizedTarget));
388
+ include('certificate', features.certificate !== false, () => this._gatherCertificate(normalizedTarget));
389
+ include('ping', features.latency?.ping !== false, () => this._runPing(normalizedTarget));
390
+ include('traceroute', features.latency?.traceroute !== false, () => this._runTraceroute(normalizedTarget));
391
+ include('curl', features.http?.curl !== false, () => this._runCurl(normalizedTarget));
392
+ include('subdomains', this._isAnyEnabled(features.subdomains), () => this._runSubdomainRecon(normalizedTarget, features.subdomains));
393
+ include('ports', this._isAnyEnabled(features.ports), () => this._runPortScans(normalizedTarget, features.ports));
394
+ include('tlsAudit', this._isAnyEnabled(features.tlsAudit), () => this._runTlsExtras(normalizedTarget, features.tlsAudit));
395
+ include('fingerprintTools', this._isAnyEnabled(features.fingerprint), () => this._runFingerprintTools(normalizedTarget, features.fingerprint));
396
+ include('webDiscovery', this._isAnyEnabled(features.web), () => this._runWebDiscovery(normalizedTarget, features.web));
397
+ include('vulnerabilityScan', this._isAnyEnabled(features.vulnerability), () => this._runVulnerabilityScans(normalizedTarget, features.vulnerability));
398
+ include('screenshots', this._isAnyEnabled(features.screenshots), () => this._runScreenshotCapture(normalizedTarget, features.screenshots));
399
+ include('osint', this._isAnyEnabled(features.osint), () => this._runOsintRecon(normalizedTarget, features.osint));
400
+
401
+ return plan;
402
+ }
403
+
404
+ _normalizeToolName(name) {
405
+ if (!name) return name;
406
+ const lower = String(name).toLowerCase();
407
+ if (['nmap', 'masscan', 'ports', 'port', 'portscan'].includes(lower)) {
408
+ return 'ports';
409
+ }
410
+ if (['subdomain', 'subdomains', 'subdomainscan'].includes(lower)) {
411
+ return 'subdomains';
412
+ }
413
+ if (lower === 'mtr') {
414
+ return 'traceroute';
415
+ }
416
+ if (lower === 'latency') {
417
+ return 'ping';
418
+ }
419
+ if (['fingerprint', 'whatweb'].includes(lower)) {
420
+ return 'fingerprintTools';
421
+ }
422
+ if (['vulnerability', 'nikto', 'wpscan', 'droopescan'].includes(lower)) {
423
+ return 'vulnerabilityScan';
424
+ }
425
+ if (['web', 'ffuf', 'feroxbuster', 'gobuster'].includes(lower)) {
426
+ return 'webDiscovery';
427
+ }
428
+ if (['screenshots', 'aquatone', 'eyewitness'].includes(lower)) {
429
+ return 'screenshots';
430
+ }
431
+ if (['osint', 'theharvester', 'recon-ng'].includes(lower)) {
432
+ return 'osint';
433
+ }
434
+ return lower;
435
+ }
436
+
437
+ _refreshResourceNames() {
438
+ const namespace = this.namespace;
439
+ this._resourceNames = {
440
+ hosts: resolveResourceName('recon-hosts', this._resourceDescriptors.hosts, { namespace }),
441
+ reports: resolveResourceName('recon-reports', this._resourceDescriptors.reports, { namespace }),
442
+ diffs: resolveResourceName('recon-diffs', this._resourceDescriptors.diffs, { namespace }),
443
+ stages: resolveResourceName('recon-stage-results', this._resourceDescriptors.stages, { namespace }),
444
+ subdomains: resolveResourceName('recon-subdomains', this._resourceDescriptors.subdomains, { namespace }),
445
+ paths: resolveResourceName('recon-paths', this._resourceDescriptors.paths, { namespace })
446
+ };
447
+ if (this._resourceCache) {
448
+ this._resourceCache.clear();
449
+ }
450
+ }
451
+
452
+ onNamespaceChanged() {
453
+ this._refreshResourceNames();
454
+ }
455
+
456
+ afterInstall() {
457
+ super.afterInstall();
458
+ if (this.database?.plugins) {
459
+ this.database.plugins.network = this;
460
+ }
461
+ }
462
+
463
+ afterUninstall() {
464
+ if (this.database?.plugins?.network === this) {
465
+ delete this.database.plugins.network;
466
+ }
467
+ super.afterUninstall();
468
+ }
469
+
470
+ async _ensureResources() {
471
+ if (!this.database) return;
472
+ const definitions = [
473
+ {
474
+ key: 'hosts',
475
+ config: {
476
+ primaryKey: 'id',
477
+ attributes: {
478
+ id: 'string|required',
479
+ target: 'string',
480
+ summary: 'object',
481
+ fingerprint: 'object',
482
+ lastScanAt: 'string',
483
+ storageKey: 'string'
484
+ },
485
+ timestamps: true,
486
+ behavior: 'user-managed'
487
+ }
488
+ },
489
+ {
490
+ key: 'reports',
491
+ config: {
492
+ primaryKey: 'id',
493
+ attributes: {
494
+ id: 'string|required',
495
+ host: 'string|required',
496
+ startedAt: 'string',
497
+ endedAt: 'string',
498
+ status: 'string',
499
+ storageKey: 'string',
500
+ stageKeys: 'object'
501
+ },
502
+ timestamps: true,
503
+ behavior: 'truncate-data'
504
+ }
505
+ },
506
+ {
507
+ key: 'diffs',
508
+ config: {
509
+ primaryKey: 'id',
510
+ attributes: {
511
+ id: 'string|required',
512
+ host: 'string|required',
513
+ timestamp: 'string|required',
514
+ changes: 'object'
515
+ },
516
+ timestamps: true,
517
+ behavior: 'truncate-data'
518
+ }
519
+ },
520
+ {
521
+ key: 'stages',
522
+ config: {
523
+ primaryKey: 'id',
524
+ attributes: {
525
+ id: 'string|required',
526
+ host: 'string|required',
527
+ stage: 'string|required',
528
+ status: 'string',
529
+ storageKey: 'string',
530
+ summary: 'object',
531
+ collectedAt: 'string'
532
+ },
533
+ timestamps: true,
534
+ behavior: 'truncate-data'
535
+ }
536
+ },
537
+ {
538
+ key: 'subdomains',
539
+ config: {
540
+ primaryKey: 'id',
541
+ attributes: {
542
+ id: 'string|required',
543
+ host: 'string|required',
544
+ subdomains: 'array',
545
+ total: 'number',
546
+ sources: 'object',
547
+ lastScanAt: 'string'
548
+ },
549
+ timestamps: true,
550
+ behavior: 'replace'
551
+ }
552
+ },
553
+ {
554
+ key: 'paths',
555
+ config: {
556
+ primaryKey: 'id',
557
+ attributes: {
558
+ id: 'string|required',
559
+ host: 'string|required',
560
+ paths: 'array',
561
+ total: 'number',
562
+ sources: 'object',
563
+ lastScanAt: 'string'
564
+ },
565
+ timestamps: true,
566
+ behavior: 'replace'
567
+ }
568
+ }
569
+ ];
570
+
571
+ for (const def of definitions) {
572
+ const name = this._resourceNames[def.key];
573
+ if (!name) continue;
574
+ const existing = this.database.resources?.[name];
575
+ if (existing) continue;
576
+ try {
577
+ await this.database.createResource({ name, ...def.config });
578
+ } catch (error) {
579
+ // Ignore if resource already exists
580
+ }
581
+ }
582
+ if (this.database.resources) {
583
+ this._resourceCache.clear();
584
+ }
585
+ }
586
+
587
+ async _getResource(key) {
588
+ if (!this.database || !this.config.resources.persist) {
589
+ return null;
590
+ }
591
+ if (this._resourceCache.has(key)) {
592
+ return this._resourceCache.get(key);
593
+ }
594
+ const name = this._resourceNames[key];
595
+ if (!name) return null;
596
+ let resource = this.database.resources?.[name] || null;
597
+ if (!resource && typeof this.database.getResource === 'function') {
598
+ try {
599
+ resource = await this.database.getResource(name);
600
+ } catch (error) {
601
+ resource = null;
602
+ }
603
+ }
604
+ if (resource) {
605
+ this._resourceCache.set(key, resource);
606
+ }
607
+ return resource;
608
+ }
609
+
610
+ _resolveBehaviorPreset(behavior, overrides = {}) {
611
+ if (!behavior || !BEHAVIOR_PRESETS[behavior]) {
612
+ return overrides;
613
+ }
614
+
615
+ const preset = BEHAVIOR_PRESETS[behavior];
616
+ const merged = {
617
+ features: this._mergeFeatures(preset.features || {}, overrides.features || {}),
618
+ concurrency: overrides.concurrency ?? preset.concurrency,
619
+ ping: { ...(preset.ping || {}), ...(overrides.ping || {}) },
620
+ traceroute: { ...(preset.traceroute || {}), ...(overrides.traceroute || {}) },
621
+ curl: { ...(preset.curl || {}), ...(overrides.curl || {}) },
622
+ nmap: { ...(preset.nmap || {}), ...(overrides.nmap || {}) },
623
+ masscan: { ...(preset.masscan || {}), ...(overrides.masscan || {}) },
624
+ rateLimit: { ...(preset.rateLimit || {}), ...(overrides.rateLimit || {}) },
625
+ tools: overrides.tools ?? preset.tools
626
+ };
627
+
628
+ this.emit('recon:behavior-applied', {
629
+ mode: behavior,
630
+ preset: preset,
631
+ overrides: overrides,
632
+ final: merged
633
+ });
634
+
635
+ return merged;
636
+ }
637
+
638
+ async _applyRateLimit(stageName) {
639
+ if (!this.config.rateLimit.enabled) {
640
+ return;
641
+ }
642
+
643
+ const delayMs = this.config.rateLimit.delayBetweenStages;
644
+ if (delayMs > 0) {
645
+ this.emit('recon:rate-limit-delay', {
646
+ stage: stageName,
647
+ delayMs
648
+ });
649
+ await new Promise(resolve => setTimeout(resolve, delayMs));
650
+ }
651
+ }
652
+
653
+ _isAnyEnabled(featureGroup) {
654
+ if (!featureGroup || typeof featureGroup !== 'object') {
655
+ return false;
656
+ }
657
+ return Object.values(featureGroup).some((value) => value === true || (typeof value === 'object' && this._isAnyEnabled(value)));
658
+ }
659
+
660
+ _mergeFeatures(base, overrides) {
661
+ if (!overrides || typeof overrides !== 'object') {
662
+ return JSON.parse(JSON.stringify(base));
663
+ }
664
+
665
+ const result = Array.isArray(base) ? [] : {};
666
+ const keys = new Set([...Object.keys(base || {}), ...Object.keys(overrides)]);
667
+
668
+ for (const key of keys) {
669
+ const baseValue = base ? base[key] : undefined;
670
+ const overrideValue = overrides[key];
671
+
672
+ if (
673
+ baseValue &&
674
+ typeof baseValue === 'object' &&
675
+ !Array.isArray(baseValue) &&
676
+ overrideValue &&
677
+ typeof overrideValue === 'object' &&
678
+ !Array.isArray(overrideValue)
679
+ ) {
680
+ result[key] = this._mergeFeatures(baseValue, overrideValue);
681
+ } else if (overrideValue !== undefined) {
682
+ result[key] = overrideValue;
683
+ } else {
684
+ result[key] = baseValue;
685
+ }
686
+ }
687
+
688
+ return result;
689
+ }
690
+
691
+ async _startScheduler() {
692
+ if (!this.config.schedule.enabled || !this.config.schedule.cron) {
693
+ return;
694
+ }
695
+
696
+ try {
697
+ if (!this._cronModule) {
698
+ this._cronModule = await import('node-cron');
699
+ }
700
+ if (this._cronJob) {
701
+ this._cronJob.stop();
702
+ }
703
+ this._cronJob = this._cronModule.schedule(this.config.schedule.cron, () => {
704
+ this._triggerScheduledSweep('cron').catch((error) => {
705
+ this.emit('recon:schedule-error', {
706
+ message: error?.message || 'Scheduled sweep failed',
707
+ error
708
+ });
709
+ });
710
+ });
711
+ } catch (error) {
712
+ this.emit('recon:schedule-error', {
713
+ message: error?.message || 'Failed to start cron scheduler',
714
+ error
715
+ });
716
+ }
717
+ }
718
+
719
+ async _triggerScheduledSweep(reason = 'manual') {
720
+ const targets = this.config.targets || [];
721
+ if (!targets.length) return;
722
+
723
+ await PromisePool.withConcurrency(this.config.concurrency)
724
+ .for(targets)
725
+ .process(async (targetConfig) => {
726
+ const targetEntry = this._normalizeTargetConfig(targetConfig);
727
+ try {
728
+ const report = await this.runDiagnostics(targetEntry.target, {
729
+ features: targetEntry.features,
730
+ tools: targetEntry.tools,
731
+ persist: targetEntry.persist ?? true
732
+ });
733
+ this.emit('recon:completed', {
734
+ reason,
735
+ target: report.target.host,
736
+ status: report.status,
737
+ endedAt: report.endedAt
738
+ });
739
+ } catch (error) {
740
+ this.emit('recon:target-error', {
741
+ reason,
742
+ target: targetEntry.target,
743
+ message: error?.message || 'Recon execution failed',
744
+ error
745
+ });
746
+ }
747
+ });
748
+ }
749
+
750
+ _normalizeTargetConfig(entry) {
751
+ if (typeof entry === 'string') {
752
+ return { target: entry };
753
+ }
754
+ if (entry && typeof entry === 'object') {
755
+ return {
756
+ target: entry.target || entry.host || entry.domain,
757
+ features: entry.features,
758
+ tools: entry.tools,
759
+ persist: entry.persist
760
+ };
761
+ }
762
+ throw new Error('Invalid target configuration for ReconPlugin');
763
+ }
764
+
765
+ async _persistReport(target, report) {
766
+ const storage = this.getStorage();
767
+ const timestamp = report.endedAt.replace(/[:.]/g, '-');
768
+ const baseKey = storage.getPluginKey(null, 'reports', target.host);
769
+ const historyKey = `${baseKey}/${timestamp}.json`;
770
+ const stageStorageKeys = {};
771
+ const toolStorageKeys = {};
772
+
773
+ for (const [stageName, stageData] of Object.entries(report.results || {})) {
774
+ // Persist individual tools if present
775
+ if (stageData._individual && typeof stageData._individual === 'object') {
776
+ for (const [toolName, toolData] of Object.entries(stageData._individual)) {
777
+ const toolKey = `${baseKey}/stages/${timestamp}/tools/${toolName}.json`;
778
+ await storage.set(toolKey, toolData, { behavior: 'body-only' });
779
+ toolStorageKeys[toolName] = toolKey;
780
+ }
781
+ }
782
+
783
+ // Persist aggregated stage view
784
+ const aggregatedData = stageData._aggregated || stageData;
785
+ const stageKey = `${baseKey}/stages/${timestamp}/aggregated/${stageName}.json`;
786
+ await storage.set(stageKey, aggregatedData, { behavior: 'body-only' });
787
+ stageStorageKeys[stageName] = stageKey;
788
+ }
789
+
790
+ report.stageStorageKeys = stageStorageKeys;
791
+ report.toolStorageKeys = toolStorageKeys;
792
+ report.storageKey = historyKey;
793
+
794
+ await storage.set(historyKey, report, { behavior: 'body-only' });
795
+ await storage.set(`${baseKey}/latest.json`, report, { behavior: 'body-only' });
796
+
797
+ const indexKey = `${baseKey}/index.json`;
798
+ const existing = (await storage.get(indexKey)) || { target: target.host, history: [] };
799
+
800
+ existing.history.unshift({
801
+ timestamp: report.endedAt,
802
+ status: report.status,
803
+ reportKey: historyKey,
804
+ stageKeys: stageStorageKeys,
805
+ toolKeys: toolStorageKeys,
806
+ summary: {
807
+ latencyMs: report.fingerprint.latencyMs ?? null,
808
+ openPorts: report.fingerprint.openPorts?.length ?? 0,
809
+ subdomains: report.fingerprint.subdomainCount ?? 0,
810
+ primaryIp: report.fingerprint.primaryIp ?? null
811
+ }
812
+ });
813
+
814
+ let pruned = [];
815
+ if (existing.history.length > this.config.storage.historyLimit) {
816
+ pruned = existing.history.splice(this.config.storage.historyLimit);
817
+ }
818
+
819
+ await storage.set(indexKey, existing, { behavior: 'body-only' });
820
+
821
+ if (pruned.length) {
822
+ await this._pruneHistory(target, pruned);
823
+ }
824
+ }
825
+
826
+ async _persistToResources(report) {
827
+ if (!this.database || !this.config.resources.persist) {
828
+ return;
829
+ }
830
+ const hostId = report.target.host;
831
+ const hostsResource = await this._getResource('hosts');
832
+ const stagesResource = await this._getResource('stages');
833
+ const reportsResource = await this._getResource('reports');
834
+ const subdomainsResource = await this._getResource('subdomains');
835
+ const pathsResource = await this._getResource('paths');
836
+
837
+ if (hostsResource) {
838
+ let existing = null;
839
+ try {
840
+ existing = await hostsResource.get(hostId);
841
+ } catch (error) {
842
+ existing = null;
843
+ }
844
+
845
+ const hostRecord = this._buildHostRecord(report);
846
+
847
+ if (existing) {
848
+ try {
849
+ await hostsResource.update(hostId, hostRecord);
850
+ } catch (error) {
851
+ if (typeof hostsResource.replace === 'function') {
852
+ await hostsResource.replace(hostId, hostRecord);
853
+ }
854
+ }
855
+ } else {
856
+ try {
857
+ await hostsResource.insert(hostRecord);
858
+ } catch (error) {
859
+ if (typeof hostsResource.replace === 'function') {
860
+ await hostsResource.replace(hostRecord.id, hostRecord);
861
+ }
862
+ }
863
+ }
864
+
865
+ const diffs = this._computeDiffs(existing, report);
866
+ if (diffs.length) {
867
+ await this._saveDiffs(hostId, report.endedAt, diffs);
868
+ await this._emitDiffAlerts(hostId, report, diffs);
869
+ }
870
+ }
871
+
872
+ if (subdomainsResource) {
873
+ const list = Array.isArray(report.results?.subdomains?.list) ? report.results.subdomains.list : [];
874
+ const subdomainRecord = {
875
+ id: hostId,
876
+ host: hostId,
877
+ subdomains: list,
878
+ total: list.length,
879
+ sources: this._stripRawFields(report.results?.subdomains?.sources || {}),
880
+ lastScanAt: report.endedAt
881
+ };
882
+ await this._upsertResourceRecord(subdomainsResource, subdomainRecord);
883
+ }
884
+
885
+ if (pathsResource) {
886
+ const pathStage = report.results?.webDiscovery || {};
887
+ const paths = Array.isArray(pathStage.paths) ? pathStage.paths : [];
888
+ const pathRecord = {
889
+ id: hostId,
890
+ host: hostId,
891
+ paths,
892
+ total: paths.length,
893
+ sources: this._stripRawFields(pathStage.tools || pathStage.sources || {}),
894
+ lastScanAt: report.endedAt
895
+ };
896
+ await this._upsertResourceRecord(pathsResource, pathRecord);
897
+ }
898
+
899
+ if (reportsResource) {
900
+ const reportRecord = {
901
+ id: `${hostId}|${report.endedAt}`,
902
+ host: hostId,
903
+ startedAt: report.startedAt,
904
+ endedAt: report.endedAt,
905
+ status: report.status,
906
+ storageKey: report.storageKey || null,
907
+ stageKeys: report.stageStorageKeys || {}
908
+ };
909
+ try {
910
+ await reportsResource.insert(reportRecord);
911
+ } catch (error) {
912
+ try {
913
+ await reportsResource.update(reportRecord.id, reportRecord);
914
+ } catch (err) {
915
+ if (typeof reportsResource.replace === 'function') {
916
+ await reportsResource.replace(reportRecord.id, reportRecord);
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ if (stagesResource && report.stageStorageKeys) {
923
+ for (const [stageName, stageData] of Object.entries(report.results || {})) {
924
+ const storageKey = report.stageStorageKeys[stageName] || null;
925
+ const summary = this._summarizeStage(stageName, stageData);
926
+ const stageRecord = {
927
+ id: `${hostId}|${stageName}|${report.endedAt}`,
928
+ host: hostId,
929
+ stage: stageName,
930
+ status: stageData?.status || 'unknown',
931
+ storageKey,
932
+ summary,
933
+ collectedAt: report.endedAt
934
+ };
935
+ try {
936
+ await stagesResource.insert(stageRecord);
937
+ } catch (error) {
938
+ try {
939
+ await stagesResource.update(stageRecord.id, stageRecord);
940
+ } catch (err) {
941
+ if (typeof stagesResource.replace === 'function') {
942
+ await stagesResource.replace(stageRecord.id, stageRecord);
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ }
949
+
950
+ async _pruneHistory(target, prunedEntries) {
951
+ const storage = this.getStorage();
952
+ const hostId = typeof target === 'string' ? target : target?.host || target?.target || target;
953
+ const reportsResource = await this._getResource('reports');
954
+ const stagesResource = await this._getResource('stages');
955
+ const diffsResource = await this._getResource('diffs');
956
+
957
+ for (const entry of prunedEntries) {
958
+ if (entry?.reportKey) {
959
+ await storage.delete(entry.reportKey).catch(() => {});
960
+ }
961
+ if (entry?.stageKeys) {
962
+ for (const key of Object.values(entry.stageKeys)) {
963
+ if (key) {
964
+ await storage.delete(key).catch(() => {});
965
+ }
966
+ }
967
+ }
968
+
969
+ if (reportsResource) {
970
+ await this._deleteResourceRecord(reportsResource, `${hostId}|${entry.timestamp}`);
971
+ }
972
+ if (stagesResource && entry?.stageKeys) {
973
+ for (const stageName of Object.keys(entry.stageKeys)) {
974
+ await this._deleteResourceRecord(stagesResource, `${hostId}|${stageName}|${entry.timestamp}`);
975
+ }
976
+ }
977
+ if (diffsResource) {
978
+ await this._deleteResourceRecord(diffsResource, `${hostId}|${entry.timestamp}`);
979
+ }
980
+ }
981
+ }
982
+
983
+ async _loadLatestReport(hostId) {
984
+ try {
985
+ const storage = this.getStorage();
986
+ const baseKey = storage.getPluginKey(null, 'reports', hostId);
987
+ const latestKey = `${baseKey}/latest.json`;
988
+ return await storage.get(latestKey);
989
+ } catch (error) {
990
+ return null;
991
+ }
992
+ }
993
+
994
+ async _loadHostSummary(hostId, fallbackReport) {
995
+ const hostsResource = await this._getResource('hosts');
996
+ if (hostsResource) {
997
+ try {
998
+ const record = await hostsResource.get(hostId);
999
+ if (record) {
1000
+ return record;
1001
+ }
1002
+ } catch (error) {
1003
+ // ignore and fallback
1004
+ }
1005
+ }
1006
+ if (fallbackReport) {
1007
+ return this._buildHostRecord(fallbackReport);
1008
+ }
1009
+ return null;
1010
+ }
1011
+
1012
+ async _loadRecentDiffs(hostId, limit = 10) {
1013
+ const diffsResource = await this._getResource('diffs');
1014
+ if (diffsResource && typeof diffsResource.query === 'function') {
1015
+ try {
1016
+ const result = await diffsResource.query({ host: hostId }, { limit, sort: '-timestamp' });
1017
+ if (Array.isArray(result)) {
1018
+ return result.slice(0, limit).map((entry) => this._normalizeDiffEntry(entry));
1019
+ }
1020
+ if (result?.items) {
1021
+ return result.items.slice(0, limit).map((entry) => this._normalizeDiffEntry(entry));
1022
+ }
1023
+ } catch (error) {
1024
+ // ignore and fallback to storage index
1025
+ }
1026
+ }
1027
+
1028
+ try {
1029
+ const storage = this.getStorage();
1030
+ const baseKey = storage.getPluginKey(null, 'reports', hostId);
1031
+ const index = await storage.get(`${baseKey}/index.json`);
1032
+ if (index?.history?.length > 1) {
1033
+ const [latest, previous] = index.history;
1034
+ if (previous) {
1035
+ const diffs = [];
1036
+ const deltaSubdomains = (latest.summary?.subdomains ?? 0) - (previous.summary?.subdomains ?? 0);
1037
+ if (deltaSubdomains !== 0) {
1038
+ diffs.push(this._normalizeDiffEntry({
1039
+ type: 'summary:subdomains',
1040
+ delta: deltaSubdomains,
1041
+ previous: previous.summary?.subdomains ?? 0,
1042
+ current: latest.summary?.subdomains ?? 0,
1043
+ timestamp: latest.timestamp,
1044
+ severity: Math.abs(deltaSubdomains) >= 5 ? 'medium' : 'info'
1045
+ }));
1046
+ }
1047
+ const deltaPorts = (latest.summary?.openPorts ?? 0) - (previous.summary?.openPorts ?? 0);
1048
+ if (deltaPorts !== 0) {
1049
+ diffs.push(this._normalizeDiffEntry({
1050
+ type: 'summary:openPorts',
1051
+ delta: deltaPorts,
1052
+ previous: previous.summary?.openPorts ?? 0,
1053
+ current: latest.summary?.openPorts ?? 0,
1054
+ timestamp: latest.timestamp,
1055
+ severity: deltaPorts > 0 ? 'high' : 'info',
1056
+ critical: deltaPorts > 0
1057
+ }));
1058
+ }
1059
+ if (latest.summary?.primaryIp && latest.summary?.primaryIp !== previous.summary?.primaryIp) {
1060
+ diffs.push(this._normalizeDiffEntry({
1061
+ type: 'field:primaryIp',
1062
+ previous: previous.summary?.primaryIp ?? null,
1063
+ current: latest.summary?.primaryIp,
1064
+ timestamp: latest.timestamp,
1065
+ severity: 'high',
1066
+ critical: true
1067
+ }));
1068
+ }
1069
+ return diffs.slice(0, limit);
1070
+ }
1071
+ }
1072
+ } catch (error) {
1073
+ // ignore
1074
+ }
1075
+
1076
+ return [];
1077
+ }
1078
+
1079
+ _normalizeDiffEntry(entry) {
1080
+ if (!entry || typeof entry !== 'object') {
1081
+ return { type: 'unknown', severity: 'info', critical: false };
1082
+ }
1083
+ const normalized = { ...entry };
1084
+ normalized.severity = (entry.severity || 'info').toLowerCase();
1085
+ normalized.critical = entry.critical === true;
1086
+ if (!normalized.description && entry.type && entry.values) {
1087
+ normalized.description = `${entry.type}: ${Array.isArray(entry.values) ? entry.values.join(', ') : entry.values}`;
1088
+ }
1089
+ return normalized;
1090
+ }
1091
+
1092
+ _collectStageSummaries(report) {
1093
+ const summaries = [];
1094
+ for (const [stageName, stageData] of Object.entries(report.results || {})) {
1095
+ const summary = this._summarizeStage(stageName, stageData);
1096
+ summaries.push({
1097
+ stage: stageName,
1098
+ status: stageData?.status || 'unknown',
1099
+ summary
1100
+ });
1101
+ }
1102
+ return summaries;
1103
+ }
1104
+
1105
+ async generateClientReport(targetInput, options = {}) {
1106
+ const format = (options.format || 'markdown').toLowerCase();
1107
+ const diffLimit = options.diffLimit ?? 10;
1108
+ const normalized = this._normalizeTarget(
1109
+ typeof targetInput === 'string'
1110
+ ? targetInput
1111
+ : targetInput?.target || targetInput?.host || targetInput
1112
+ );
1113
+ const hostId = normalized.host;
1114
+
1115
+ const report = options.report || (await this._loadLatestReport(hostId));
1116
+ if (!report) {
1117
+ throw new Error(`No recon report found for host "${hostId}"`);
1118
+ }
1119
+
1120
+ const hostSummary = await this._loadHostSummary(hostId, report);
1121
+ const diffs = await this._loadRecentDiffs(hostId, diffLimit);
1122
+ const stages = this._collectStageSummaries(report);
1123
+
1124
+ if (format === 'json') {
1125
+ return {
1126
+ host: hostSummary,
1127
+ report,
1128
+ diffs,
1129
+ stages
1130
+ };
1131
+ }
1132
+
1133
+ return this._buildMarkdownReport(hostSummary, report, stages, diffs, options);
1134
+ }
1135
+
1136
+ _buildMarkdownReport(hostSummary, report, stages, diffs, options) {
1137
+ const lines = [];
1138
+ const summary = hostSummary?.summary || this._buildHostRecord(report).summary;
1139
+ const fingerprint = hostSummary?.fingerprint || report.fingerprint || {};
1140
+ const target = hostSummary?.target || report.target.original;
1141
+
1142
+ lines.push(`# Recon Report – ${target}`);
1143
+ lines.push('');
1144
+ lines.push(`- **Última execução:** ${report.endedAt}`);
1145
+ lines.push(`- **Status geral:** ${report.status || 'desconhecido'}`);
1146
+ if (summary.primaryIp) {
1147
+ lines.push(`- **IP primário:** ${summary.primaryIp}`);
1148
+ }
1149
+ if ((summary.ipAddresses || []).length > 1) {
1150
+ lines.push(`- **IPs adicionais:** ${summary.ipAddresses.slice(1).join(', ')}`);
1151
+ }
1152
+ if (summary.cdn) {
1153
+ lines.push(`- **CDN/WAF:** ${summary.cdn}`);
1154
+ }
1155
+ if (summary.server) {
1156
+ lines.push(`- **Servidor:** ${summary.server}`);
1157
+ }
1158
+ if (summary.latencyMs !== null && summary.latencyMs !== undefined) {
1159
+ lines.push(`- **Latência média:** ${summary.latencyMs.toFixed ? summary.latencyMs.toFixed(1) : summary.latencyMs} ms`);
1160
+ }
1161
+ if ((summary.technologies || []).length) {
1162
+ lines.push(`- **Tecnologias:** ${summary.technologies.join(', ')}`);
1163
+ }
1164
+
1165
+ lines.push('');
1166
+ if ((summary.openPorts || []).length) {
1167
+ lines.push('## Portas abertas');
1168
+ lines.push('');
1169
+ lines.push('| Porta | Serviço | Detalhe |');
1170
+ lines.push('|-------|---------|---------|');
1171
+ for (const port of summary.openPorts) {
1172
+ const portLabel = port.port || port;
1173
+ const service = port.service || '';
1174
+ const detail = port.detail || port.version || '';
1175
+ lines.push(`| ${portLabel} | ${service} | ${detail} |`);
1176
+ }
1177
+ lines.push('');
1178
+ }
1179
+
1180
+ if ((summary.subdomains || []).length) {
1181
+ lines.push('## Principais subdomínios');
1182
+ lines.push('');
1183
+ for (const sub of summary.subdomains.slice(0, 20)) {
1184
+ lines.push(`- ${sub}`);
1185
+ }
1186
+ if (summary.subdomainCount > 20) {
1187
+ lines.push(`- ... (+${summary.subdomainCount - 20} extras)`);
1188
+ }
1189
+ lines.push('');
1190
+ }
1191
+
1192
+ if (stages.length) {
1193
+ lines.push('## Resumo por estágio');
1194
+ lines.push('');
1195
+ lines.push('| Estágio | Status | Observações |');
1196
+ lines.push('|---------|--------|-------------|');
1197
+ for (const stage of stages) {
1198
+ const status = stage.status || 'desconhecido';
1199
+ const notes = stage.summary && Object.keys(stage.summary).length
1200
+ ? Object.entries(stage.summary)
1201
+ .slice(0, 3)
1202
+ .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.length : value}`)
1203
+ .join('; ')
1204
+ : '';
1205
+ lines.push(`| ${stage.stage} | ${status} | ${notes} |`);
1206
+ }
1207
+ lines.push('');
1208
+ }
1209
+
1210
+ if (diffs.length) {
1211
+ lines.push('## Mudanças recentes');
1212
+ lines.push('');
1213
+ for (const diff of diffs.slice(0, options.diffLimit ?? 10)) {
1214
+ lines.push(`- ${this._formatDiffEntry(diff)}`);
1215
+ }
1216
+ lines.push('');
1217
+ }
1218
+
1219
+ lines.push('---');
1220
+ lines.push('_Gerado automaticamente pelo ReconPlugin._');
1221
+ lines.push('');
1222
+
1223
+ return lines.join('\n');
1224
+ }
1225
+
1226
+ _formatDiffEntry(diff) {
1227
+ if (!diff) return 'Mudança não especificada';
1228
+ const prefix = diff.severity === 'high' ? '🚨 ' : diff.severity === 'medium' ? '⚠️ ' : '';
1229
+ if (diff.description) {
1230
+ return `${prefix}${diff.description}`;
1231
+ }
1232
+ const type = diff.type || 'desconhecido';
1233
+ switch (type) {
1234
+ case 'subdomain:add':
1235
+ return `${prefix}Novos subdomínios: ${(diff.values || []).join(', ')}`;
1236
+ case 'subdomain:remove':
1237
+ return `${prefix}Subdomínios removidos: ${(diff.values || []).join(', ')}`;
1238
+ case 'port:add':
1239
+ return `${prefix}Novas portas expostas: ${(diff.values || []).join(', ')}`;
1240
+ case 'port:remove':
1241
+ return `${prefix}Portas fechadas: ${(diff.values || []).join(', ')}`;
1242
+ case 'technology:add':
1243
+ return `${prefix}Tecnologias adicionadas: ${(diff.values || []).join(', ')}`;
1244
+ case 'technology:remove':
1245
+ return `${prefix}Tecnologias removidas: ${(diff.values || []).join(', ')}`;
1246
+ case 'field:primaryIp':
1247
+ return `${prefix}IP primário alterado de ${diff.previous || 'desconhecido'} para ${diff.current || 'desconhecido'}`;
1248
+ case 'field:cdn':
1249
+ return `${prefix}CDN/WAF alterado de ${diff.previous || 'desconhecido'} para ${diff.current || 'desconhecido'}`;
1250
+ case 'field:server':
1251
+ return `${prefix}Servidor alterado de ${diff.previous || 'desconhecido'} para ${diff.current || 'desconhecido'}`;
1252
+ case 'summary:subdomains':
1253
+ return `${prefix}Contagem de subdomínios mudou de ${diff.previous} para ${diff.current}`;
1254
+ case 'summary:openPorts':
1255
+ return `${prefix}Contagem de portas abertas mudou de ${diff.previous} para ${diff.current}`;
1256
+ default:
1257
+ return `${prefix}${type}: ${diff.values ? diff.values.join(', ') : ''}`;
1258
+ }
1259
+ }
1260
+
1261
+ _buildHostRecord(report) {
1262
+ const fingerprint = report.fingerprint || {};
1263
+ const summary = {
1264
+ target: report.target.original,
1265
+ primaryIp: fingerprint.primaryIp || null,
1266
+ ipAddresses: fingerprint.ipAddresses || [],
1267
+ cdn: fingerprint.cdn || null,
1268
+ server: fingerprint.server || null,
1269
+ latencyMs: fingerprint.latencyMs ?? null,
1270
+ subdomains: fingerprint.subdomains || [],
1271
+ subdomainCount: (fingerprint.subdomains || []).length,
1272
+ openPorts: fingerprint.openPorts || [],
1273
+ openPortCount: (fingerprint.openPorts || []).length,
1274
+ technologies: fingerprint.technologies || []
1275
+ };
1276
+
1277
+ return {
1278
+ id: report.target.host,
1279
+ target: report.target.original,
1280
+ summary,
1281
+ fingerprint,
1282
+ lastScanAt: report.endedAt,
1283
+ storageKey: report.storageKey || null
1284
+ };
1285
+ }
1286
+
1287
+ _computeDiffs(existingRecord, report) {
1288
+ const diffs = [];
1289
+ const prevFingerprint = existingRecord?.fingerprint || {};
1290
+ const currFingerprint = report.fingerprint || {};
1291
+
1292
+ const prevSubs = new Set(prevFingerprint.subdomains || []);
1293
+ const currSubs = new Set(currFingerprint.subdomains || (report.results?.subdomains?.list || []));
1294
+ const addedSubs = [...currSubs].filter((value) => !prevSubs.has(value));
1295
+ const removedSubs = [...prevSubs].filter((value) => !currSubs.has(value));
1296
+ if (addedSubs.length) {
1297
+ diffs.push(this._createDiff('subdomain:add', {
1298
+ values: addedSubs,
1299
+ description: `Novos subdomínios: ${addedSubs.join(', ')}`
1300
+ }, { severity: 'medium', critical: false }));
1301
+ }
1302
+ if (removedSubs.length) {
1303
+ diffs.push(this._createDiff('subdomain:remove', {
1304
+ values: removedSubs,
1305
+ description: `Subdomínios removidos: ${removedSubs.join(', ')}`
1306
+ }, { severity: 'low', critical: false }));
1307
+ }
1308
+
1309
+ const normalizePort = (entry) => {
1310
+ if (!entry) return entry;
1311
+ if (typeof entry === 'string') return entry;
1312
+ return entry.port || `${entry.service || 'unknown'}`;
1313
+ };
1314
+ const prevPorts = new Set((prevFingerprint.openPorts || []).map(normalizePort));
1315
+ const currPorts = new Set((currFingerprint.openPorts || []).map(normalizePort));
1316
+ const addedPorts = [...currPorts].filter((value) => !prevPorts.has(value));
1317
+ const removedPorts = [...prevPorts].filter((value) => !currPorts.has(value));
1318
+ if (addedPorts.length) {
1319
+ diffs.push(this._createDiff('port:add', {
1320
+ values: addedPorts,
1321
+ description: `Novas portas expostas: ${addedPorts.join(', ')}`
1322
+ }, { severity: 'high', critical: true }));
1323
+ }
1324
+ if (removedPorts.length) {
1325
+ diffs.push(this._createDiff('port:remove', {
1326
+ values: removedPorts,
1327
+ description: `Portas fechadas: ${removedPorts.join(', ')}`
1328
+ }, { severity: 'low', critical: false }));
1329
+ }
1330
+
1331
+ const prevTech = new Set((prevFingerprint.technologies || []).map((tech) => tech.toLowerCase()));
1332
+ const currTech = new Set((currFingerprint.technologies || []).map((tech) => tech.toLowerCase()));
1333
+ const addedTech = [...currTech].filter((value) => !prevTech.has(value));
1334
+ const removedTech = [...prevTech].filter((value) => !currTech.has(value));
1335
+ if (addedTech.length) {
1336
+ diffs.push(this._createDiff('technology:add', {
1337
+ values: addedTech,
1338
+ description: `Tecnologias adicionadas: ${addedTech.join(', ')}`
1339
+ }, { severity: 'medium', critical: false }));
1340
+ }
1341
+ if (removedTech.length) {
1342
+ diffs.push(this._createDiff('technology:remove', {
1343
+ values: removedTech,
1344
+ description: `Tecnologias removidas: ${removedTech.join(', ')}`
1345
+ }, { severity: 'low', critical: false }));
1346
+ }
1347
+
1348
+ const primitiveFields = ['primaryIp', 'cdn', 'server'];
1349
+ for (const field of primitiveFields) {
1350
+ const previous = prevFingerprint[field] ?? null;
1351
+ const current = currFingerprint[field] ?? null;
1352
+ if (previous !== current) {
1353
+ const severity = field === 'primaryIp' ? 'high' : 'medium';
1354
+ const critical = field === 'primaryIp';
1355
+ diffs.push(this._createDiff(`field:${field}`, {
1356
+ previous,
1357
+ current,
1358
+ description: `${field} alterado de ${previous ?? 'desconhecido'} para ${current ?? 'desconhecido'}`
1359
+ }, { severity, critical }));
1360
+ }
1361
+ }
1362
+
1363
+ return diffs;
1364
+ }
1365
+
1366
+ _createDiff(type, payload = {}, meta = {}) {
1367
+ const { severity = 'info', critical = false } = meta;
1368
+ return {
1369
+ type,
1370
+ severity,
1371
+ critical,
1372
+ ...payload
1373
+ };
1374
+ }
1375
+
1376
+ async _saveDiffs(hostId, timestamp, diffs) {
1377
+ const diffsResource = await this._getResource('diffs');
1378
+ if (!diffsResource) return;
1379
+ const record = {
1380
+ id: `${hostId}|${timestamp}`,
1381
+ host: hostId,
1382
+ timestamp,
1383
+ changes: diffs,
1384
+ alerts: diffs.filter((diff) => diff.critical)
1385
+ };
1386
+ try {
1387
+ await diffsResource.insert(record);
1388
+ } catch (error) {
1389
+ try {
1390
+ await diffsResource.update(record.id, record);
1391
+ } catch (err) {
1392
+ if (typeof diffsResource.replace === 'function') {
1393
+ await diffsResource.replace(record.id, record);
1394
+ }
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ _summarizeStage(stageName, stageResult) {
1400
+ if (!stageResult) return null;
1401
+ const clone = this._deepClone(stageResult);
1402
+ const sanitized = this._stripRawFields(clone);
1403
+
1404
+ if (stageName === 'subdomains' && Array.isArray(sanitized.list)) {
1405
+ sanitized.total = sanitized.list.length;
1406
+ sanitized.sample = sanitized.list.slice(0, 20);
1407
+ delete sanitized.list;
1408
+ }
1409
+
1410
+ if (stageName === 'ports' && Array.isArray(sanitized.openPorts)) {
1411
+ sanitized.total = sanitized.openPorts.length;
1412
+ sanitized.sample = sanitized.openPorts.slice(0, 10);
1413
+ }
1414
+
1415
+ if (stageName === 'webDiscovery' && Array.isArray(sanitized.paths)) {
1416
+ sanitized.total = sanitized.paths.length;
1417
+ sanitized.sample = sanitized.paths.slice(0, 20);
1418
+ delete sanitized.paths;
1419
+ }
1420
+
1421
+ return sanitized;
1422
+ }
1423
+
1424
+ _deepClone(value) {
1425
+ if (typeof structuredClone === 'function') {
1426
+ try {
1427
+ return structuredClone(value);
1428
+ } catch (error) {
1429
+ // fallback
1430
+ }
1431
+ }
1432
+ return JSON.parse(JSON.stringify(value));
1433
+ }
1434
+
1435
+ _stripRawFields(value) {
1436
+ if (Array.isArray(value)) {
1437
+ return value.map((entry) => this._stripRawFields(entry));
1438
+ }
1439
+ if (value && typeof value === 'object') {
1440
+ const result = {};
1441
+ for (const [key, nested] of Object.entries(value)) {
1442
+ if (['raw', 'stderr', 'stdout'].includes(key)) {
1443
+ continue;
1444
+ }
1445
+ result[key] = this._stripRawFields(nested);
1446
+ }
1447
+ return result;
1448
+ }
1449
+ return value;
1450
+ }
1451
+
1452
+ async _emitDiffAlerts(hostId, report, diffs) {
1453
+ const criticalDiffs = diffs.filter((diff) => diff.critical);
1454
+ if (!criticalDiffs.length) {
1455
+ return;
1456
+ }
1457
+ const alerts = criticalDiffs.map((diff) => ({
1458
+ host: hostId,
1459
+ stage: diff.type,
1460
+ severity: diff.severity || 'info',
1461
+ description: diff.description,
1462
+ values: diff.values || null,
1463
+ previous: diff.previous,
1464
+ current: diff.current,
1465
+ timestamp: report.endedAt,
1466
+ reportKey: report.storageKey || null
1467
+ }));
1468
+
1469
+ for (const alert of alerts) {
1470
+ this.emit('recon:alert', alert);
1471
+ }
1472
+ }
1473
+
1474
+ async _deleteResourceRecord(resource, id) {
1475
+ if (!resource || !id) return;
1476
+ const methods = ['delete', 'remove', 'del'];
1477
+ for (const method of methods) {
1478
+ if (typeof resource[method] === 'function') {
1479
+ try {
1480
+ await resource[method](id);
1481
+ } catch (error) {
1482
+ // ignore
1483
+ }
1484
+ return;
1485
+ }
1486
+ }
1487
+ }
1488
+
1489
+ async _upsertResourceRecord(resource, record) {
1490
+ if (!resource || !record?.id) return;
1491
+ try {
1492
+ await resource.insert(record);
1493
+ return;
1494
+ } catch (error) {
1495
+ // fallthrough to update/replace
1496
+ }
1497
+
1498
+ const methods = ['update', 'replace'];
1499
+ for (const method of methods) {
1500
+ if (typeof resource[method] !== 'function') {
1501
+ continue;
1502
+ }
1503
+ try {
1504
+ await resource[method](record.id, record);
1505
+ return;
1506
+ } catch (error) {
1507
+ // try next
1508
+ }
1509
+ }
1510
+ }
1511
+
1512
+ async getHostSummary(targetInput, options = {}) {
1513
+ const normalized = this._normalizeTarget(
1514
+ typeof targetInput === 'string'
1515
+ ? targetInput
1516
+ : targetInput?.target || targetInput?.host || targetInput
1517
+ );
1518
+ const hostId = normalized.host;
1519
+ const report = options.report || (await this._loadLatestReport(hostId));
1520
+ if (!report) {
1521
+ return null;
1522
+ }
1523
+ const hostRecord = await this._loadHostSummary(hostId, report);
1524
+ if (!hostRecord) {
1525
+ return null;
1526
+ }
1527
+ if (options.includeDiffs) {
1528
+ hostRecord.diffs = await this._loadRecentDiffs(hostId, options.diffLimit ?? 10);
1529
+ }
1530
+ return hostRecord;
1531
+ }
1532
+
1533
+ async getRecentAlerts(targetInput, options = {}) {
1534
+ const normalized = this._normalizeTarget(
1535
+ typeof targetInput === 'string'
1536
+ ? targetInput
1537
+ : targetInput?.target || targetInput?.host || targetInput
1538
+ );
1539
+ const hostId = normalized.host;
1540
+ const limit = options.limit ?? 5;
1541
+ const diffs = await this._loadRecentDiffs(hostId, limit * 2);
1542
+ return diffs.filter((diff) => diff.critical).slice(0, limit);
1543
+ }
1544
+
1545
+
1546
+ async _gatherDns(target) {
1547
+ const result = {
1548
+ status: 'ok',
1549
+ records: {},
1550
+ errors: {}
1551
+ };
1552
+
1553
+ try {
1554
+ const lookups = await Promise.allSettled([
1555
+ dns.lookup(target.host, { all: true }),
1556
+ dns.resolve4(target.host),
1557
+ dns.resolve6(target.host).catch(() => []),
1558
+ dns.resolveNs(target.host).catch(() => []),
1559
+ dns.resolveMx(target.host).catch(() => []),
1560
+ dns.resolveTxt(target.host).catch(() => [])
1561
+ ]);
1562
+
1563
+ const [lookupAll, aRecords, aaaaRecords, nsRecords, mxRecords, txtRecords] = lookups;
1564
+
1565
+ if (lookupAll.status === 'fulfilled') {
1566
+ result.records.lookup = lookupAll.value;
1567
+ } else {
1568
+ result.errors.lookup = lookupAll.reason?.message;
1569
+ }
1570
+
1571
+ result.records.a = aRecords.status === 'fulfilled' ? aRecords.value : [];
1572
+ if (aRecords.status === 'rejected') {
1573
+ result.errors.a = aRecords.reason?.message;
1574
+ }
1575
+
1576
+ result.records.aaaa = aaaaRecords.status === 'fulfilled' ? aaaaRecords.value : [];
1577
+ if (aaaaRecords.status === 'rejected') {
1578
+ result.errors.aaaa = aaaaRecords.reason?.message;
1579
+ }
1580
+
1581
+ result.records.ns = nsRecords.status === 'fulfilled' ? nsRecords.value : [];
1582
+ if (nsRecords.status === 'rejected') {
1583
+ result.errors.ns = nsRecords.reason?.message;
1584
+ }
1585
+
1586
+ result.records.mx = mxRecords.status === 'fulfilled' ? mxRecords.value : [];
1587
+ if (mxRecords.status === 'rejected') {
1588
+ result.errors.mx = mxRecords.reason?.message;
1589
+ }
1590
+
1591
+ result.records.txt = txtRecords.status === 'fulfilled' ? txtRecords.value : [];
1592
+ if (txtRecords.status === 'rejected') {
1593
+ result.errors.txt = txtRecords.reason?.message;
1594
+ }
1595
+
1596
+ const allIps = [
1597
+ ...(result.records.a || []),
1598
+ ...(result.records.aaaa || [])
1599
+ ];
1600
+
1601
+ if (allIps.length > 0) {
1602
+ const reverseLookups = await Promise.allSettled(
1603
+ allIps.map(async (ip) => {
1604
+ try {
1605
+ const hosts = await dns.reverse(ip);
1606
+ return { ip, hosts };
1607
+ } catch (error) {
1608
+ return { ip, hosts: [], error };
1609
+ }
1610
+ })
1611
+ );
1612
+
1613
+ result.records.reverse = {};
1614
+ for (const entry of reverseLookups) {
1615
+ if (entry.status === 'fulfilled') {
1616
+ const { ip, hosts, error } = entry.value;
1617
+ result.records.reverse[ip] = hosts;
1618
+ if (error) {
1619
+ result.errors[`reverse:${ip}`] = error?.message;
1620
+ }
1621
+ } else if (entry.reason?.ip) {
1622
+ result.records.reverse[entry.reason.ip] = [];
1623
+ result.errors[`reverse:${entry.reason.ip}`] = entry.reason.error?.message;
1624
+ }
1625
+ }
1626
+ } else {
1627
+ result.records.reverse = {};
1628
+ }
1629
+ } catch (error) {
1630
+ result.status = 'error';
1631
+ result.message = error?.message || 'DNS lookup failed';
1632
+ }
1633
+
1634
+ return result;
1635
+ }
1636
+
1637
+ async _gatherCertificate(target) {
1638
+ const shouldCheckTls =
1639
+ target.protocol === 'https' ||
1640
+ (!target.protocol && (target.port === 443 || target.host.includes(':') === false));
1641
+
1642
+ if (!shouldCheckTls) {
1643
+ return {
1644
+ status: 'skipped',
1645
+ message: 'TLS inspection skipped for non-HTTPS target'
1646
+ };
1647
+ }
1648
+
1649
+ const port = target.port || 443;
1650
+
1651
+ return new Promise((resolve) => {
1652
+ const socket = tls.connect(
1653
+ {
1654
+ host: target.host,
1655
+ port,
1656
+ servername: target.host,
1657
+ rejectUnauthorized: false,
1658
+ timeout: 5000
1659
+ },
1660
+ () => {
1661
+ const certificate = socket.getPeerCertificate(true);
1662
+ socket.end();
1663
+ if (!certificate || Object.keys(certificate).length === 0) {
1664
+ resolve({
1665
+ status: 'error',
1666
+ message: 'No certificate information available'
1667
+ });
1668
+ return;
1669
+ }
1670
+
1671
+ resolve({
1672
+ status: 'ok',
1673
+ subject: certificate.subject,
1674
+ issuer: certificate.issuer,
1675
+ validFrom: certificate.valid_from,
1676
+ validTo: certificate.valid_to,
1677
+ fingerprint: certificate.fingerprint256 || certificate.fingerprint,
1678
+ subjectAltName: certificate.subjectaltname
1679
+ ? certificate.subjectaltname.split(',').map((entry) => entry.trim())
1680
+ : [],
1681
+ raw: certificate
1682
+ });
1683
+ }
1684
+ );
1685
+
1686
+ socket.on('error', (error) => {
1687
+ resolve({
1688
+ status: 'error',
1689
+ message: error?.message || 'Unable to retrieve certificate'
1690
+ });
1691
+ });
1692
+
1693
+ socket.setTimeout(6000, () => {
1694
+ socket.destroy();
1695
+ resolve({
1696
+ status: 'timeout',
1697
+ message: 'TLS handshake timed out'
1698
+ });
1699
+ });
1700
+ });
1701
+ }
1702
+
1703
+ async _runPing(target) {
1704
+ const args = ['-n', '-c', String(this.config.ping.count), target.host];
1705
+ const run = await this.commandRunner.run('ping', args, {
1706
+ timeout: this.config.ping.timeout
1707
+ });
1708
+
1709
+ if (!run.ok) {
1710
+ return {
1711
+ status: 'unavailable',
1712
+ message: run.error?.message || 'Ping failed',
1713
+ stderr: run.stderr
1714
+ };
1715
+ }
1716
+
1717
+ const metrics = this._parsePingOutput(run.stdout);
1718
+
1719
+ return {
1720
+ status: 'ok',
1721
+ stdout: run.stdout,
1722
+ metrics
1723
+ };
1724
+ }
1725
+
1726
+ async _runTraceroute(target) {
1727
+ if (await this.commandRunner.isAvailable('mtr')) {
1728
+ const args = [
1729
+ '--report',
1730
+ '--report-cycles',
1731
+ String(this.config.traceroute.cycles),
1732
+ '--json',
1733
+ target.host
1734
+ ];
1735
+ const mtrResult = await this.commandRunner.run('mtr', args, {
1736
+ timeout: this.config.traceroute.timeout,
1737
+ maxBuffer: 4 * 1024 * 1024
1738
+ });
1739
+
1740
+ if (mtrResult.ok) {
1741
+ try {
1742
+ const parsed = JSON.parse(mtrResult.stdout);
1743
+ return {
1744
+ status: 'ok',
1745
+ type: 'mtr',
1746
+ report: parsed
1747
+ };
1748
+ } catch (error) {
1749
+ // Fallback to plain text interpretation
1750
+ return {
1751
+ status: 'ok',
1752
+ type: 'mtr',
1753
+ stdout: mtrResult.stdout
1754
+ };
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ if (await this.commandRunner.isAvailable('traceroute')) {
1760
+ const tracerouteResult = await this.commandRunner.run(
1761
+ 'traceroute',
1762
+ ['-n', target.host],
1763
+ {
1764
+ timeout: this.config.traceroute.timeout
1765
+ }
1766
+ );
1767
+
1768
+ if (tracerouteResult.ok) {
1769
+ return {
1770
+ status: 'ok',
1771
+ type: 'traceroute',
1772
+ stdout: tracerouteResult.stdout
1773
+ };
1774
+ }
1775
+ return {
1776
+ status: 'error',
1777
+ message: tracerouteResult.error?.message || 'Traceroute failed',
1778
+ stderr: tracerouteResult.stderr
1779
+ };
1780
+ }
1781
+
1782
+ return {
1783
+ status: 'unavailable',
1784
+ message: 'mtr/traceroute commands not available'
1785
+ };
1786
+ }
1787
+
1788
+ async _runSubdomainRecon(target, featureConfig = {}) {
1789
+ const aggregated = new Set();
1790
+ const sources = {};
1791
+
1792
+ const executeCliCollector = async (name, command, args, parser) => {
1793
+ if (!featureConfig[name]) {
1794
+ return;
1795
+ }
1796
+ const run = await this.commandRunner.run(command, args, { timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
1797
+ if (!run.ok) {
1798
+ sources[name] = {
1799
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
1800
+ message: run.error?.message || `${command} failed`,
1801
+ stderr: run.stderr
1802
+ };
1803
+ return;
1804
+ }
1805
+ const items = parser(run.stdout, run.stderr);
1806
+ items.forEach((item) => aggregated.add(item));
1807
+ sources[name] = {
1808
+ status: 'ok',
1809
+ count: items.length,
1810
+ sample: items.slice(0, 10)
1811
+ };
1812
+ if (this.config.storage.persistRawOutput) {
1813
+ sources[name].raw = this._truncateOutput(run.stdout);
1814
+ }
1815
+ };
1816
+
1817
+ await executeCliCollector('amass', 'amass', ['enum', '-d', target.host, '-o', '-'], (stdout) =>
1818
+ stdout
1819
+ .split(/\r?\n/)
1820
+ .map((line) => line.trim())
1821
+ .filter(Boolean)
1822
+ );
1823
+
1824
+ await executeCliCollector('subfinder', 'subfinder', ['-d', target.host, '-silent'], (stdout) =>
1825
+ stdout
1826
+ .split(/\r?\n/)
1827
+ .map((line) => line.trim())
1828
+ .filter(Boolean)
1829
+ );
1830
+
1831
+ await executeCliCollector('assetfinder', 'assetfinder', ['--subs-only', target.host], (stdout) =>
1832
+ stdout
1833
+ .split(/\r?\n/)
1834
+ .map((line) => line.trim())
1835
+ .filter(Boolean)
1836
+ );
1837
+
1838
+ if (featureConfig.crtsh) {
1839
+ try {
1840
+ const response = await fetch(`https://crt.sh/?q=%25.${target.host}&output=json`, {
1841
+ headers: { 'User-Agent': this.config.curl.userAgent },
1842
+ signal: AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined
1843
+ });
1844
+ if (response.ok) {
1845
+ const data = await response.json();
1846
+ const entries = Array.isArray(data) ? data : [];
1847
+ const hostnames = entries
1848
+ .map((entry) => entry.name_value)
1849
+ .filter(Boolean)
1850
+ .flatMap((value) => value.split('\n'))
1851
+ .map((value) => value.trim())
1852
+ .filter(Boolean);
1853
+ hostnames.forEach((hostname) => aggregated.add(hostname));
1854
+ sources.crtsh = {
1855
+ status: 'ok',
1856
+ count: hostnames.length,
1857
+ sample: hostnames.slice(0, 10)
1858
+ };
1859
+ } else {
1860
+ sources.crtsh = {
1861
+ status: 'error',
1862
+ message: `crt.sh responded with status ${response.status}`
1863
+ };
1864
+ }
1865
+ } catch (error) {
1866
+ sources.crtsh = {
1867
+ status: 'error',
1868
+ message: error?.message || 'crt.sh lookup failed'
1869
+ };
1870
+ }
1871
+ }
1872
+
1873
+ const list = Array.from(aggregated).sort();
1874
+
1875
+ return {
1876
+ _individual: sources,
1877
+ _aggregated: {
1878
+ status: list.length > 0 ? 'ok' : 'empty',
1879
+ total: list.length,
1880
+ list,
1881
+ sources
1882
+ },
1883
+ status: list.length > 0 ? 'ok' : 'empty',
1884
+ total: list.length,
1885
+ list,
1886
+ sources
1887
+ };
1888
+ }
1889
+
1890
+ async _runPortScans(target, featureConfig = {}) {
1891
+ const scanners = {};
1892
+ const openPorts = new Map();
1893
+
1894
+ if (featureConfig.nmap) {
1895
+ const result = await this._runNmap(target, { extraArgs: featureConfig.nmapArgs });
1896
+ scanners.nmap = result;
1897
+ if (result.status === 'ok' && Array.isArray(result.summary?.openPorts)) {
1898
+ for (const entry of result.summary.openPorts) {
1899
+ openPorts.set(entry.port, entry);
1900
+ }
1901
+ }
1902
+ }
1903
+
1904
+ if (featureConfig.masscan) {
1905
+ const result = await this._runMasscan(target, featureConfig.masscan);
1906
+ scanners.masscan = result;
1907
+ if (result.status === 'ok' && Array.isArray(result.openPorts)) {
1908
+ for (const entry of result.openPorts) {
1909
+ if (!openPorts.has(entry.port)) {
1910
+ openPorts.set(entry.port, entry);
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ return {
1917
+ _individual: scanners,
1918
+ _aggregated: {
1919
+ status: openPorts.size > 0 ? 'ok' : 'empty',
1920
+ openPorts: Array.from(openPorts.values()),
1921
+ scanners
1922
+ },
1923
+ status: openPorts.size > 0 ? 'ok' : 'empty',
1924
+ openPorts: Array.from(openPorts.values()),
1925
+ scanners
1926
+ };
1927
+ }
1928
+
1929
+ async _runCurl(target) {
1930
+ const url = this._buildUrl(target);
1931
+ const args = [
1932
+ '-I',
1933
+ '-sS',
1934
+ '-L',
1935
+ '--max-time',
1936
+ String(Math.ceil(this.config.curl.timeout / 1000)),
1937
+ '--user-agent',
1938
+ this.config.curl.userAgent,
1939
+ url
1940
+ ];
1941
+
1942
+ const result = await this.commandRunner.run('curl', args, {
1943
+ timeout: this.config.curl.timeout
1944
+ });
1945
+
1946
+ if (!result.ok) {
1947
+ return {
1948
+ status: 'unavailable',
1949
+ message: result.error?.message || 'curl failed',
1950
+ stderr: result.stderr
1951
+ };
1952
+ }
1953
+
1954
+ const headers = this._parseCurlHeaders(result.stdout);
1955
+
1956
+ return {
1957
+ status: 'ok',
1958
+ url,
1959
+ headers,
1960
+ raw: this.config.storage.persistRawOutput ? this._truncateOutput(result.stdout) : undefined
1961
+ };
1962
+ }
1963
+
1964
+ async _runNmap(target, options = {}) {
1965
+ if (!(await this.commandRunner.isAvailable('nmap'))) {
1966
+ return {
1967
+ status: 'unavailable',
1968
+ message: 'nmap is not available on this system'
1969
+ };
1970
+ }
1971
+
1972
+ const topPorts = options.topPorts ?? this.config.nmap.topPorts;
1973
+ const extraArgs = options.extraArgs ?? this.config.nmap.extraArgs;
1974
+
1975
+ const args = [
1976
+ '-Pn',
1977
+ '--top-ports',
1978
+ String(topPorts),
1979
+ target.host,
1980
+ ...extraArgs
1981
+ ];
1982
+
1983
+ const result = await this.commandRunner.run('nmap', args, {
1984
+ timeout: 20000,
1985
+ maxBuffer: 4 * 1024 * 1024
1986
+ });
1987
+
1988
+ if (!result.ok) {
1989
+ return {
1990
+ status: 'error',
1991
+ message: result.error?.message || 'nmap scan failed',
1992
+ stderr: result.stderr
1993
+ };
1994
+ }
1995
+
1996
+ return {
1997
+ status: 'ok',
1998
+ summary: this._parseNmapOutput(result.stdout),
1999
+ raw: this.config.storage.persistRawOutput ? this._truncateOutput(result.stdout) : undefined
2000
+ };
2001
+ }
2002
+
2003
+ async _runMasscan(target, featureConfig = {}) {
2004
+ if (!(await this.commandRunner.isAvailable('masscan'))) {
2005
+ return {
2006
+ status: 'unavailable',
2007
+ message: 'masscan is not available on this system'
2008
+ };
2009
+ }
2010
+
2011
+ const ports = featureConfig.ports ?? '1-65535';
2012
+ const rate = featureConfig.rate ?? 1000;
2013
+
2014
+ const args = ['-p', ports, target.host, '--rate', String(rate), '--wait', '0'];
2015
+ const result = await this.commandRunner.run('masscan', args, {
2016
+ timeout: featureConfig.timeout ?? 30000,
2017
+ maxBuffer: 4 * 1024 * 1024
2018
+ });
2019
+
2020
+ if (!result.ok) {
2021
+ return {
2022
+ status: result.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2023
+ message: result.error?.message || 'masscan scan failed',
2024
+ stderr: result.stderr
2025
+ };
2026
+ }
2027
+
2028
+ const openPorts = result.stdout
2029
+ .split(/\r?\n/)
2030
+ .map((line) => line.trim())
2031
+ .filter((line) => line.toLowerCase().startsWith('discovered open port'))
2032
+ .map((line) => {
2033
+ const parts = line.split(' ');
2034
+ const portProto = parts[3];
2035
+ const ip = parts[5];
2036
+ return {
2037
+ port: portProto,
2038
+ ip
2039
+ };
2040
+ });
2041
+
2042
+ return {
2043
+ status: openPorts.length ? 'ok' : 'empty',
2044
+ openPorts,
2045
+ raw: this.config.storage.persistRawOutput ? this._truncateOutput(result.stdout) : undefined
2046
+ };
2047
+ }
2048
+
2049
+ async _runWebDiscovery(target, featureConfig = {}) {
2050
+ if (!featureConfig) {
2051
+ return { status: 'disabled' };
2052
+ }
2053
+
2054
+ const tools = {};
2055
+ const discovered = {};
2056
+ const allPaths = new Set();
2057
+ const wordlist = featureConfig.wordlist;
2058
+ const threads = featureConfig.threads ?? 50;
2059
+
2060
+ const runDirBuster = async (name, command, args) => {
2061
+ const run = await this.commandRunner.run(command, args, {
2062
+ timeout: featureConfig.timeout ?? 60000,
2063
+ maxBuffer: 8 * 1024 * 1024
2064
+ });
2065
+ if (!run.ok) {
2066
+ tools[name] = {
2067
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2068
+ message: run.error?.message || `${command} failed`,
2069
+ stderr: run.stderr
2070
+ };
2071
+ return;
2072
+ }
2073
+ const findings = run.stdout
2074
+ .split(/\r?\n/)
2075
+ .map((line) => line.trim())
2076
+ .filter(Boolean);
2077
+ discovered[name] = findings;
2078
+ findings.forEach((item) => allPaths.add(item));
2079
+ tools[name] = {
2080
+ status: 'ok',
2081
+ count: findings.length,
2082
+ sample: findings.slice(0, 10)
2083
+ };
2084
+ if (this.config.storage.persistRawOutput) {
2085
+ tools[name].raw = this._truncateOutput(run.stdout);
2086
+ }
2087
+ };
2088
+
2089
+ if (featureConfig.ffuf && wordlist) {
2090
+ await runDirBuster('ffuf', 'ffuf', ['-u', `${this._buildUrl(target)}/FUZZ`, '-w', wordlist, '-t', String(threads), '-mc', '200,204,301,302,307,401,403']);
2091
+ }
2092
+
2093
+ if (featureConfig.feroxbuster && wordlist) {
2094
+ await runDirBuster('feroxbuster', 'feroxbuster', ['-u', this._buildUrl(target), '-w', wordlist, '--threads', String(threads), '--silent']);
2095
+ }
2096
+
2097
+ if (featureConfig.gobuster && wordlist) {
2098
+ await runDirBuster('gobuster', 'gobuster', ['dir', '-u', this._buildUrl(target), '-w', wordlist, '-t', String(threads)]);
2099
+ }
2100
+
2101
+ const total = Object.values(discovered).reduce((acc, list) => acc + list.length, 0);
2102
+
2103
+ if (!total) {
2104
+ return {
2105
+ status: wordlist ? 'empty' : 'skipped',
2106
+ message: wordlist ? 'No endpoints discovered' : 'Wordlist not provided',
2107
+ tools
2108
+ };
2109
+ }
2110
+
2111
+ const paths = Array.from(allPaths);
2112
+
2113
+ return {
2114
+ _individual: tools,
2115
+ _aggregated: {
2116
+ status: 'ok',
2117
+ total,
2118
+ tools,
2119
+ paths
2120
+ },
2121
+ status: 'ok',
2122
+ total,
2123
+ tools,
2124
+ paths
2125
+ };
2126
+ }
2127
+
2128
+ async _runVulnerabilityScans(target, featureConfig = {}) {
2129
+ const tools = {};
2130
+
2131
+ const execute = async (name, command, args) => {
2132
+ const run = await this.commandRunner.run(command, args, {
2133
+ timeout: featureConfig.timeout ?? 60000,
2134
+ maxBuffer: 8 * 1024 * 1024
2135
+ });
2136
+ if (!run.ok) {
2137
+ tools[name] = {
2138
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2139
+ message: run.error?.message || `${command} failed`,
2140
+ stderr: run.stderr
2141
+ };
2142
+ return;
2143
+ }
2144
+ tools[name] = {
2145
+ status: 'ok'
2146
+ };
2147
+ if (this.config.storage.persistRawOutput) {
2148
+ tools[name].raw = this._truncateOutput(run.stdout);
2149
+ }
2150
+ };
2151
+
2152
+ if (featureConfig.nikto) {
2153
+ await execute('nikto', 'nikto', ['-h', this._buildUrl(target), '-ask', 'no']);
2154
+ }
2155
+
2156
+ if (featureConfig.wpscan) {
2157
+ await execute('wpscan', 'wpscan', ['--url', this._buildUrl(target), '--random-user-agent']);
2158
+ }
2159
+
2160
+ if (featureConfig.droopescan) {
2161
+ await execute('droopescan', 'droopescan', ['scan', 'drupal', '-u', this._buildUrl(target)]);
2162
+ }
2163
+
2164
+ if (Object.keys(tools).length === 0) {
2165
+ return { status: 'skipped' };
2166
+ }
2167
+
2168
+ return {
2169
+ _individual: tools,
2170
+ _aggregated: {
2171
+ status: Object.values(tools).some((tool) => tool.status === 'ok') ? 'ok' : 'empty',
2172
+ tools
2173
+ },
2174
+ status: Object.values(tools).some((tool) => tool.status === 'ok') ? 'ok' : 'empty',
2175
+ tools
2176
+ };
2177
+ }
2178
+
2179
+ async _runTlsExtras(target, featureConfig = {}) {
2180
+ const tools = {};
2181
+ const port = target.port || 443;
2182
+
2183
+ const execute = async (name, command, args) => {
2184
+ const run = await this.commandRunner.run(command, args, {
2185
+ timeout: featureConfig.timeout ?? 20000,
2186
+ maxBuffer: 4 * 1024 * 1024
2187
+ });
2188
+ if (!run.ok) {
2189
+ tools[name] = {
2190
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2191
+ message: run.error?.message || `${command} failed`,
2192
+ stderr: run.stderr
2193
+ };
2194
+ return;
2195
+ }
2196
+ tools[name] = {
2197
+ status: 'ok'
2198
+ };
2199
+ if (this.config.storage.persistRawOutput) {
2200
+ tools[name].raw = this._truncateOutput(run.stdout);
2201
+ }
2202
+ };
2203
+
2204
+ if (featureConfig.openssl) {
2205
+ await execute('openssl', 'openssl', ['s_client', '-servername', target.host, '-connect', `${target.host}:${port}`, '-brief']);
2206
+ }
2207
+
2208
+ if (featureConfig.sslyze) {
2209
+ await execute('sslyze', 'sslyze', [target.host]);
2210
+ }
2211
+
2212
+ if (featureConfig.testssl) {
2213
+ await execute('testssl', 'testssl.sh', ['--quiet', `${target.host}:${port}`]);
2214
+ }
2215
+
2216
+ if (Object.keys(tools).length === 0) {
2217
+ return { status: 'skipped' };
2218
+ }
2219
+
2220
+ return {
2221
+ _individual: tools,
2222
+ _aggregated: {
2223
+ status: Object.values(tools).some((tool) => tool.status === 'ok') ? 'ok' : 'empty',
2224
+ tools
2225
+ },
2226
+ status: Object.values(tools).some((tool) => tool.status === 'ok') ? 'ok' : 'empty',
2227
+ tools
2228
+ };
2229
+ }
2230
+
2231
+ async _runFingerprintTools(target, featureConfig = {}) {
2232
+ const technologies = new Set();
2233
+ const tools = {};
2234
+
2235
+ if (featureConfig.whatweb) {
2236
+ const run = await this.commandRunner.run('whatweb', ['-q', this._buildUrl(target)], {
2237
+ timeout: featureConfig.timeout ?? 20000,
2238
+ maxBuffer: 2 * 1024 * 1024
2239
+ });
2240
+ if (run.ok) {
2241
+ const parsed = run.stdout
2242
+ .split(/[\r\n]+/)
2243
+ .flatMap((line) => line.split(' '))
2244
+ .map((token) => token.trim())
2245
+ .filter((token) => token.includes('[') && token.includes(']'))
2246
+ .map((token) => token.substring(0, token.indexOf('[')));
2247
+ parsed.forEach((tech) => technologies.add(tech));
2248
+ tools.whatweb = { status: 'ok', technologies: parsed.slice(0, 20) };
2249
+ if (this.config.storage.persistRawOutput) {
2250
+ tools.whatweb.raw = this._truncateOutput(run.stdout);
2251
+ }
2252
+ } else {
2253
+ tools.whatweb = {
2254
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2255
+ message: run.error?.message || 'whatweb failed'
2256
+ };
2257
+ }
2258
+ }
2259
+
2260
+ if (technologies.size === 0 && Object.keys(tools).length === 0) {
2261
+ return { status: 'skipped' };
2262
+ }
2263
+
2264
+ return {
2265
+ _individual: tools,
2266
+ _aggregated: {
2267
+ status: technologies.size ? 'ok' : 'empty',
2268
+ technologies: Array.from(technologies),
2269
+ tools
2270
+ },
2271
+ status: technologies.size ? 'ok' : 'empty',
2272
+ technologies: Array.from(technologies),
2273
+ tools
2274
+ };
2275
+ }
2276
+
2277
+ async _runScreenshotCapture(target, featureConfig = {}) {
2278
+ if (!featureConfig.aquatone && !featureConfig.eyewitness) {
2279
+ return { status: 'skipped' };
2280
+ }
2281
+
2282
+ const screenshots = {};
2283
+ const hostsFile = await this._writeTempHostsFile([this._buildUrl(target)]);
2284
+
2285
+ const execute = async (name, command, args) => {
2286
+ const run = await this.commandRunner.run(command, args, {
2287
+ timeout: featureConfig.timeout ?? 60000,
2288
+ maxBuffer: 4 * 1024 * 1024
2289
+ });
2290
+ if (!run.ok) {
2291
+ screenshots[name] = {
2292
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2293
+ message: run.error?.message || `${command} failed`
2294
+ };
2295
+ return;
2296
+ }
2297
+ screenshots[name] = { status: 'ok' };
2298
+ };
2299
+
2300
+ if (featureConfig.aquatone) {
2301
+ const outputDir = featureConfig.outputDir || path.join(os.tmpdir(), `aquatone-${randomUUID()}`);
2302
+ await fs.mkdir(outputDir, { recursive: true });
2303
+ await execute('aquatone', 'aquatone', ['-scan-timeout', '20000', '-out', outputDir, '-list', hostsFile]);
2304
+ screenshots.aquatone.outputDir = outputDir;
2305
+ }
2306
+
2307
+ if (featureConfig.eyewitness) {
2308
+ const outputDir = featureConfig.outputDir || path.join(os.tmpdir(), `eyewitness-${randomUUID()}`);
2309
+ await fs.mkdir(outputDir, { recursive: true });
2310
+ await execute('eyewitness', 'EyeWitness', ['--web', '--timeout', '20', '--threads', '5', '--headless', '-f', hostsFile, '-d', outputDir]);
2311
+ screenshots.eyewitness = { status: 'ok', outputDir };
2312
+ }
2313
+
2314
+ await fs.rm(hostsFile, { force: true });
2315
+
2316
+ if (Object.values(screenshots).some((entry) => entry.status === 'ok')) {
2317
+ return {
2318
+ _individual: screenshots,
2319
+ _aggregated: {
2320
+ status: 'ok',
2321
+ tools: screenshots
2322
+ },
2323
+ status: 'ok',
2324
+ tools: screenshots
2325
+ };
2326
+ }
2327
+
2328
+ return {
2329
+ _individual: screenshots,
2330
+ _aggregated: {
2331
+ status: 'empty',
2332
+ tools: screenshots
2333
+ },
2334
+ status: 'empty',
2335
+ tools: screenshots
2336
+ };
2337
+ }
2338
+
2339
+ async _runOsintRecon(target, featureConfig = {}) {
2340
+ const tools = {};
2341
+
2342
+ if (featureConfig.theHarvester) {
2343
+ const run = await this.commandRunner.run('theHarvester', ['-d', target.host, '-b', 'all'], {
2344
+ timeout: featureConfig.timeout ?? 60000,
2345
+ maxBuffer: 4 * 1024 * 1024
2346
+ });
2347
+ if (run.ok) {
2348
+ tools.theHarvester = {
2349
+ status: 'ok'
2350
+ };
2351
+ if (this.config.storage.persistRawOutput) {
2352
+ tools.theHarvester.raw = this._truncateOutput(run.stdout);
2353
+ }
2354
+ } else {
2355
+ tools.theHarvester = {
2356
+ status: run.error?.code === 'ENOENT' ? 'unavailable' : 'error',
2357
+ message: run.error?.message || 'theHarvester failed'
2358
+ };
2359
+ }
2360
+ }
2361
+
2362
+ if (featureConfig.reconNg) {
2363
+ tools.reconNg = {
2364
+ status: 'manual',
2365
+ message: 'recon-ng requires interactive scripting; run via custom scripts'
2366
+ };
2367
+ }
2368
+
2369
+ if (Object.keys(tools).length === 0) {
2370
+ return { status: 'skipped' };
2371
+ }
2372
+
2373
+ return {
2374
+ _individual: tools,
2375
+ _aggregated: {
2376
+ status: Object.values(tools).some((entry) => entry.status === 'ok') ? 'ok' : 'empty',
2377
+ tools
2378
+ },
2379
+ status: Object.values(tools).some((entry) => entry.status === 'ok') ? 'ok' : 'empty',
2380
+ tools
2381
+ };
2382
+ }
2383
+
2384
+ async _writeTempHostsFile(hosts) {
2385
+ const filePath = path.join(os.tmpdir(), `recon-plugin-${randomUUID()}.txt`);
2386
+ await fs.writeFile(filePath, hosts.join('\n'), { encoding: 'utf8' });
2387
+ return filePath;
2388
+ }
2389
+
2390
+ _truncateOutput(text, limit = 32768) {
2391
+ if (typeof text !== 'string') {
2392
+ return text;
2393
+ }
2394
+ if (text.length <= limit) {
2395
+ return text;
2396
+ }
2397
+ return `${text.slice(0, limit)}\n… truncated ${text.length - limit} characters`;
2398
+ }
2399
+
2400
+ _buildFingerprint(target, results) {
2401
+ const summary = {
2402
+ target: target.host,
2403
+ primaryIp: null,
2404
+ ipAddresses: [],
2405
+ cdn: null,
2406
+ server: null,
2407
+ technologies: [],
2408
+ openPorts: [],
2409
+ latencyMs: null,
2410
+ certificate: null,
2411
+ notes: [],
2412
+ pathCount: 0,
2413
+ pathsSample: []
2414
+ };
2415
+
2416
+ const dnsInfo = results.dns;
2417
+ const curlInfo = results.curl;
2418
+ const pingInfo = results.ping;
2419
+ const portsInfo = results.ports;
2420
+ const certificateInfo = results.certificate;
2421
+ const subdomainInfo = results.subdomains;
2422
+ const fingerprintTools = results.fingerprintTools;
2423
+ const webDiscoveryInfo = results.webDiscovery;
2424
+
2425
+ if (dnsInfo?.records?.a?.length) {
2426
+ summary.ipAddresses.push(...dnsInfo.records.a);
2427
+ summary.primaryIp = dnsInfo.records.a[0];
2428
+ } else if (dnsInfo?.records?.lookup?.length) {
2429
+ summary.ipAddresses.push(...dnsInfo.records.lookup.map((entry) => entry.address));
2430
+ summary.primaryIp = summary.ipAddresses[0] || null;
2431
+ }
2432
+
2433
+ if (pingInfo?.metrics?.avg !== undefined) {
2434
+ summary.latencyMs = pingInfo.metrics.avg;
2435
+ }
2436
+
2437
+ if (certificateInfo?.status === 'ok') {
2438
+ summary.certificate = {
2439
+ subject: certificateInfo.subject,
2440
+ issuer: certificateInfo.issuer,
2441
+ validFrom: certificateInfo.validFrom,
2442
+ validTo: certificateInfo.validTo
2443
+ };
2444
+ }
2445
+
2446
+ if (curlInfo?.headers) {
2447
+ if (curlInfo.headers.server) {
2448
+ summary.server = curlInfo.headers.server;
2449
+ summary.technologies.push(curlInfo.headers.server);
2450
+ }
2451
+ if (curlInfo.headers['x-powered-by']) {
2452
+ summary.technologies.push(curlInfo.headers['x-powered-by']);
2453
+ }
2454
+ if (curlInfo.headers['cf-cache-status'] || curlInfo.headers['cf-ray']) {
2455
+ summary.cdn = 'Cloudflare';
2456
+ }
2457
+ if (!summary.cdn && curlInfo.headers.via?.includes('cloudfront.net')) {
2458
+ summary.cdn = 'AWS CloudFront';
2459
+ }
2460
+ if (!summary.cdn && curlInfo.headers['x-akamai-request-id']) {
2461
+ summary.cdn = 'Akamai';
2462
+ }
2463
+ if (curlInfo.headers['x-cache']) {
2464
+ summary.notes.push(`Cache hint: ${curlInfo.headers['x-cache']}`);
2465
+ }
2466
+ }
2467
+
2468
+ if (portsInfo?.openPorts?.length) {
2469
+ summary.openPorts = portsInfo.openPorts;
2470
+ if (portsInfo.scanners?.nmap?.summary?.detectedServices) {
2471
+ summary.technologies.push(...portsInfo.scanners.nmap.summary.detectedServices);
2472
+ }
2473
+ }
2474
+
2475
+ if (subdomainInfo?.list) {
2476
+ summary.subdomains = subdomainInfo.list;
2477
+ summary.subdomainCount = subdomainInfo.list.length;
2478
+ summary.subdomainsSample = subdomainInfo.list.slice(0, 20);
2479
+ } else {
2480
+ summary.subdomains = [];
2481
+ summary.subdomainCount = 0;
2482
+ summary.subdomainsSample = [];
2483
+ }
2484
+
2485
+ if (Array.isArray(webDiscoveryInfo?.paths)) {
2486
+ summary.pathCount = webDiscoveryInfo.paths.length;
2487
+ summary.pathsSample = webDiscoveryInfo.paths.slice(0, 20);
2488
+ } else {
2489
+ summary.pathCount = 0;
2490
+ summary.pathsSample = [];
2491
+ }
2492
+
2493
+ if (fingerprintTools?.technologies) {
2494
+ summary.technologies.push(...fingerprintTools.technologies);
2495
+ }
2496
+
2497
+ const reverseRecords = dnsInfo?.records?.reverse || {};
2498
+ const relatedDomains = Object.values(reverseRecords)
2499
+ .flat()
2500
+ .filter(Boolean);
2501
+
2502
+ if (relatedDomains.length > 0) {
2503
+ summary.relatedHosts = Array.from(new Set(relatedDomains));
2504
+ } else {
2505
+ summary.relatedHosts = [];
2506
+ }
2507
+
2508
+ summary.technologies = Array.from(
2509
+ new Set(
2510
+ summary.technologies
2511
+ .filter(Boolean)
2512
+ .flatMap((value) => value.split(',').map((v) => v.trim()).filter(Boolean))
2513
+ )
2514
+ );
2515
+
2516
+ summary.ipAddresses = Array.from(new Set(summary.ipAddresses));
2517
+ summary.openPorts = summary.openPorts || [];
2518
+
2519
+ return summary;
2520
+ }
2521
+
2522
+ _parsePingOutput(text) {
2523
+ const metrics = {
2524
+ packetsTransmitted: null,
2525
+ packetsReceived: null,
2526
+ packetLoss: null,
2527
+ min: null,
2528
+ avg: null,
2529
+ max: null,
2530
+ stdDev: null
2531
+ };
2532
+
2533
+ const packetLine = text.split('\n').find((line) => line.includes('packets transmitted'));
2534
+ if (packetLine) {
2535
+ const match = packetLine.match(/(\d+)\s+packets transmitted,\s+(\d+)\s+received,.*?([\d.]+)% packet loss/);
2536
+ if (match) {
2537
+ metrics.packetsTransmitted = Number(match[1]);
2538
+ metrics.packetsReceived = Number(match[2]);
2539
+ metrics.packetLoss = Number(match[3]);
2540
+ }
2541
+ }
2542
+
2543
+ const statsLine = text.split('\n').find((line) => line.includes('min/avg/max'));
2544
+ if (statsLine) {
2545
+ const match = statsLine.match(/=\s*([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/);
2546
+ if (match) {
2547
+ metrics.min = Number(match[1]);
2548
+ metrics.avg = Number(match[2]);
2549
+ metrics.max = Number(match[3]);
2550
+ metrics.stdDev = Number(match[4]);
2551
+ }
2552
+ }
2553
+
2554
+ return metrics;
2555
+ }
2556
+
2557
+ _parseCurlHeaders(raw) {
2558
+ const lines = raw.split(/\r?\n/).filter(Boolean);
2559
+ const headers = {};
2560
+ for (const line of lines) {
2561
+ if (!line.includes(':')) continue;
2562
+ const [key, ...rest] = line.split(':');
2563
+ headers[key.trim().toLowerCase()] = rest.join(':').trim();
2564
+ }
2565
+ return headers;
2566
+ }
2567
+
2568
+ _parseNmapOutput(raw) {
2569
+ const lines = raw.split('\n');
2570
+ const openPorts = [];
2571
+ const detectedServices = [];
2572
+
2573
+ for (const line of lines) {
2574
+ const match = line.match(/^(\d+\/[a-z]+)\s+(open|filtered|closed)\s+([^\s]+)(.*)$/);
2575
+ if (match && match[2] === 'open') {
2576
+ const port = match[1];
2577
+ const service = match[3];
2578
+ const detail = match[4]?.trim();
2579
+ openPorts.push({ port, service, detail });
2580
+ detectedServices.push(`${service}${detail ? ` ${detail}` : ''}`.trim());
2581
+ }
2582
+ }
2583
+
2584
+ return {
2585
+ openPorts,
2586
+ detectedServices: Array.from(new Set(detectedServices))
2587
+ };
2588
+ }
2589
+
2590
+ _normalizeTarget(target) {
2591
+ if (!target || typeof target !== 'string') {
2592
+ throw new Error('Target must be a non-empty string');
2593
+ }
2594
+
2595
+ let url;
2596
+ try {
2597
+ url = new URL(target.includes('://') ? target : `https://${target}`);
2598
+ } catch (error) {
2599
+ url = new URL(`https://${target}`);
2600
+ }
2601
+
2602
+ const protocol = url.protocol ? url.protocol.replace(':', '') : null;
2603
+ const host = url.hostname || target;
2604
+ const port = url.port ? Number(url.port) : this._defaultPortForProtocol(protocol);
2605
+
2606
+ return {
2607
+ original: target,
2608
+ host,
2609
+ protocol,
2610
+ port,
2611
+ path: url.pathname === '/' ? null : url.pathname
2612
+ };
2613
+ }
2614
+
2615
+ _buildUrl(target) {
2616
+ const protocol = target.protocol || (target.port === 443 ? 'https' : 'http');
2617
+ const portPart =
2618
+ target.port && ![80, 443].includes(target.port) ? `:${target.port}` : '';
2619
+ return `${protocol}://${target.host}${portPart}${target.path ?? ''}`;
2620
+ }
2621
+
2622
+ _defaultPortForProtocol(protocol) {
2623
+ switch (protocol) {
2624
+ case 'https':
2625
+ return 443;
2626
+ case 'http':
2627
+ return 80;
2628
+ default:
2629
+ return null;
2630
+ }
2631
+ }
2632
+ }
2633
+
2634
+ export { ReconPlugin as NetworkPlugin };
2635
+ export default ReconPlugin;