@paa1997/metho 1.0.0
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.
- package/bin/metho.js +3 -0
- package/package.json +25 -0
- package/scripts/findsomething_secrets.py +1389 -0
- package/src/cli.js +76 -0
- package/src/constants.js +20 -0
- package/src/engine.js +180 -0
- package/src/gui.html +772 -0
- package/src/logger.js +95 -0
- package/src/server.js +261 -0
- package/src/signals.js +101 -0
- package/src/steps/base-step.js +111 -0
- package/src/steps/filter.js +43 -0
- package/src/steps/findsomething.js +42 -0
- package/src/steps/gau.js +86 -0
- package/src/steps/katana.js +61 -0
- package/src/steps/subdomain-probe.js +19 -0
- package/src/steps/subfinder.js +22 -0
- package/src/tools/installer.js +111 -0
- package/src/tools/manager.js +114 -0
- package/src/tools/registry.js +45 -0
- package/src/utils/dedup.js +37 -0
- package/src/utils/file-splitter.js +48 -0
- package/src/utils/filter-extensions.js +52 -0
package/src/logger.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
|
|
5
|
+
export function createLogger(debugMode = false) {
|
|
6
|
+
let logFilePath = null;
|
|
7
|
+
let eventCallback = null;
|
|
8
|
+
|
|
9
|
+
function timestamp() {
|
|
10
|
+
return new Date().toISOString().replace('T', ' ').replace('Z', '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeToFile(level, msg) {
|
|
14
|
+
if (!logFilePath) return;
|
|
15
|
+
try {
|
|
16
|
+
appendFileSync(logFilePath, `[${timestamp()}] [${level}] ${msg}\n`);
|
|
17
|
+
} catch { /* ignore file write errors */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function emit(event) {
|
|
21
|
+
if (eventCallback) {
|
|
22
|
+
event.timestamp = timestamp();
|
|
23
|
+
eventCallback(event);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
setLogFile(filePath) {
|
|
29
|
+
logFilePath = filePath;
|
|
30
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
onEvent(callback) {
|
|
34
|
+
eventCallback = callback;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
info(msg) {
|
|
38
|
+
console.log(chalk.blue('ℹ'), msg);
|
|
39
|
+
writeToFile('INFO', msg);
|
|
40
|
+
emit({ type: 'log', level: 'info', message: msg });
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
debug(msg) {
|
|
44
|
+
writeToFile('DEBUG', msg);
|
|
45
|
+
if (debugMode) {
|
|
46
|
+
console.log(chalk.gray(` [debug] ${msg}`));
|
|
47
|
+
}
|
|
48
|
+
emit({ type: 'log', level: 'debug', message: msg });
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
success(msg) {
|
|
52
|
+
console.log(chalk.green('✔'), msg);
|
|
53
|
+
writeToFile('SUCCESS', msg);
|
|
54
|
+
emit({ type: 'log', level: 'success', message: msg });
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
error(msg) {
|
|
58
|
+
console.error(chalk.red('✖'), msg);
|
|
59
|
+
writeToFile('ERROR', msg);
|
|
60
|
+
emit({ type: 'log', level: 'error', message: msg });
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
warn(msg) {
|
|
64
|
+
console.log(chalk.yellow('⚠'), msg);
|
|
65
|
+
writeToFile('WARN', msg);
|
|
66
|
+
emit({ type: 'log', level: 'warn', message: msg });
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
step(stepNum, name, status) {
|
|
70
|
+
const prefix = chalk.cyan(`[Step ${stepNum}]`);
|
|
71
|
+
if (status === 'start') {
|
|
72
|
+
console.log(`\n${prefix} ${chalk.bold(name)} ${chalk.gray('starting...')}`);
|
|
73
|
+
} else if (status === 'done') {
|
|
74
|
+
console.log(`${prefix} ${chalk.bold(name)} ${chalk.green('done')}`);
|
|
75
|
+
} else if (status === 'skip') {
|
|
76
|
+
console.log(`${prefix} ${chalk.bold(name)} ${chalk.yellow('skipped')}`);
|
|
77
|
+
}
|
|
78
|
+
writeToFile('STEP', `[Step ${stepNum}] ${name} - ${status}`);
|
|
79
|
+
emit({ type: 'step', stepNum, name, status });
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
result(stepName, file, lines) {
|
|
83
|
+
writeToFile('RESULT', `${stepName}: ${lines} lines → ${file}`);
|
|
84
|
+
emit({ type: 'result', step: stepName, file, lines });
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
banner(msg) {
|
|
88
|
+
console.log(chalk.bold.magenta(`\n${'═'.repeat(60)}`));
|
|
89
|
+
console.log(chalk.bold.magenta(` ${msg}`));
|
|
90
|
+
console.log(chalk.bold.magenta(`${'═'.repeat(60)}\n`));
|
|
91
|
+
writeToFile('BANNER', msg);
|
|
92
|
+
emit({ type: 'banner', message: msg });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve, dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { exec } from 'child_process';
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
import { runPipeline } from './engine.js';
|
|
8
|
+
import { createRunContext } from './signals.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export function startServer(port = 3000) {
|
|
13
|
+
let sseClients = [];
|
|
14
|
+
let runCounter = 0;
|
|
15
|
+
// Track active runs: runId → { ctx, logger, label, config }
|
|
16
|
+
const activeRuns = new Map();
|
|
17
|
+
|
|
18
|
+
// Read HTML at startup
|
|
19
|
+
let guiHtml;
|
|
20
|
+
try {
|
|
21
|
+
guiHtml = readFileSync(join(__dirname, 'gui.html'), 'utf-8');
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error('Failed to read gui.html:', err.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function broadcast(event) {
|
|
28
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
29
|
+
const dead = [];
|
|
30
|
+
for (let i = 0; i < sseClients.length; i++) {
|
|
31
|
+
try {
|
|
32
|
+
sseClients[i].write(data);
|
|
33
|
+
} catch {
|
|
34
|
+
dead.push(i);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (let i = dead.length - 1; i >= 0; i--) {
|
|
38
|
+
sseClients.splice(dead[i], 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readBody(req) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let body = '';
|
|
45
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
46
|
+
req.on('end', () => resolve(body));
|
|
47
|
+
req.on('error', reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = createServer(async (req, res) => {
|
|
52
|
+
const url = req.url.split('?')[0];
|
|
53
|
+
const method = req.method;
|
|
54
|
+
|
|
55
|
+
console.log(`[server] ${method} ${url}`);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
59
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
60
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
61
|
+
|
|
62
|
+
if (method === 'OPTIONS') {
|
|
63
|
+
res.writeHead(204);
|
|
64
|
+
return res.end();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// GET / → serve GUI
|
|
68
|
+
if (method === 'GET' && url === '/') {
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
70
|
+
return res.end(guiHtml);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// GET /status → active runs info
|
|
74
|
+
if (method === 'GET' && url === '/status') {
|
|
75
|
+
const runs = [];
|
|
76
|
+
for (const [id, run] of activeRuns) {
|
|
77
|
+
runs.push({ runId: id, label: run.label });
|
|
78
|
+
}
|
|
79
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
80
|
+
return res.end(JSON.stringify({ running: activeRuns.size, runs }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GET /events → SSE stream
|
|
84
|
+
if (method === 'GET' && url === '/events') {
|
|
85
|
+
res.writeHead(200, {
|
|
86
|
+
'Content-Type': 'text/event-stream',
|
|
87
|
+
'Cache-Control': 'no-cache',
|
|
88
|
+
'Connection': 'keep-alive',
|
|
89
|
+
'X-Accel-Buffering': 'no',
|
|
90
|
+
});
|
|
91
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
92
|
+
sseClients.push(res);
|
|
93
|
+
req.on('close', () => {
|
|
94
|
+
sseClients = sseClients.filter(c => c !== res);
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// POST /run → start a new pipeline run
|
|
100
|
+
if (method === 'POST' && url === '/run') {
|
|
101
|
+
const body = await readBody(req);
|
|
102
|
+
let params;
|
|
103
|
+
try {
|
|
104
|
+
params = JSON.parse(body);
|
|
105
|
+
} catch {
|
|
106
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
107
|
+
return res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!params.domain && !params.domainList) {
|
|
111
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
112
|
+
return res.end(JSON.stringify({ error: 'Must provide domain or domainList' }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
runCounter++;
|
|
116
|
+
const runId = 'run-' + runCounter;
|
|
117
|
+
const label = params.domain || params.domainList;
|
|
118
|
+
|
|
119
|
+
const config = {
|
|
120
|
+
domain: params.domain || null,
|
|
121
|
+
domainList: params.domainList ? resolve(params.domainList) : null,
|
|
122
|
+
outputDir: resolve(params.outputDir || './metho-results'),
|
|
123
|
+
skipSubfinder: !!params.skipSubfinder,
|
|
124
|
+
skipSubdomainProbe: !!params.skipSubdomainProbe,
|
|
125
|
+
skipGau: !!params.skipGau,
|
|
126
|
+
skipFilter: !!params.skipFilter,
|
|
127
|
+
skipKatana: !!params.skipKatana,
|
|
128
|
+
skipFindsomething: !!params.skipFindsomething,
|
|
129
|
+
katanaChunkSize: parseInt(params.katanaChunkSize, 10) || 1000,
|
|
130
|
+
katanaDepth: parseInt(params.katanaDepth, 10) || 2,
|
|
131
|
+
findsomethingPath: params.findsomethingPath || '',
|
|
132
|
+
debug: true,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
136
|
+
res.end(JSON.stringify({ ok: true, runId, label }));
|
|
137
|
+
console.log(`[server] Starting ${runId} (${label})`);
|
|
138
|
+
|
|
139
|
+
setImmediate(() => startPipelineRun(runId, label, config));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// POST /stop → stop a specific run or all runs
|
|
144
|
+
if (method === 'POST' && url === '/stop') {
|
|
145
|
+
const body = await readBody(req);
|
|
146
|
+
let params = {};
|
|
147
|
+
try { params = JSON.parse(body); } catch { /* empty body = stop all */ }
|
|
148
|
+
|
|
149
|
+
if (params.runId) {
|
|
150
|
+
// Stop a specific run
|
|
151
|
+
const run = activeRuns.get(params.runId);
|
|
152
|
+
if (run) {
|
|
153
|
+
run.ctx.triggerInterrupt(run.logger);
|
|
154
|
+
console.log(`[server] Stopping ${params.runId}`);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
// Stop all runs
|
|
158
|
+
for (const [id, run] of activeRuns) {
|
|
159
|
+
run.ctx.triggerInterrupt(run.logger);
|
|
160
|
+
console.log(`[server] Stopping ${id}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
165
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// GET /file?path=... → read result file
|
|
169
|
+
if (method === 'GET' && url === '/file') {
|
|
170
|
+
const fullUrl = new URL(req.url, `http://localhost:${port}`);
|
|
171
|
+
const filePath = fullUrl.searchParams.get('path');
|
|
172
|
+
if (!filePath) {
|
|
173
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
174
|
+
return res.end('Missing path parameter');
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
178
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
179
|
+
return res.end(content);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
182
|
+
return res.end('File not found: ' + err.message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 404
|
|
187
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
188
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error('[server] Request handler error:', err);
|
|
191
|
+
if (!res.headersSent) {
|
|
192
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
193
|
+
}
|
|
194
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
async function startPipelineRun(runId, label, config) {
|
|
199
|
+
const ctx = createRunContext();
|
|
200
|
+
const logger = createLogger(true);
|
|
201
|
+
|
|
202
|
+
activeRuns.set(runId, { ctx, logger, label, config });
|
|
203
|
+
|
|
204
|
+
// Prefix all SSE events with runId
|
|
205
|
+
logger.onEvent((event) => {
|
|
206
|
+
broadcast({ ...event, runId });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Notify GUI of new run
|
|
210
|
+
broadcast({ type: 'run-started', runId, label });
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const result = await runPipeline(config, logger, ctx);
|
|
214
|
+
const results = result?.results || [];
|
|
215
|
+
const runDir = result?.runDir || '';
|
|
216
|
+
broadcast({ type: 'complete', runId, results, runDir });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`[server] Pipeline ${runId} error:`, err);
|
|
219
|
+
broadcast({ type: 'error', runId, message: err.message });
|
|
220
|
+
} finally {
|
|
221
|
+
activeRuns.delete(runId);
|
|
222
|
+
broadcast({ type: 'run-ended', runId, activeRuns: activeRuns.size });
|
|
223
|
+
|
|
224
|
+
// Close SSE clients only when ALL runs are done
|
|
225
|
+
if (activeRuns.size === 0) {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
if (activeRuns.size === 0) {
|
|
228
|
+
for (const client of sseClients) {
|
|
229
|
+
try { client.end(); } catch { /* ignore */ }
|
|
230
|
+
}
|
|
231
|
+
sseClients = [];
|
|
232
|
+
}
|
|
233
|
+
}, 3000);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
server.on('error', (err) => {
|
|
239
|
+
if (err.code === 'EADDRINUSE') {
|
|
240
|
+
console.error(`Port ${port} is already in use. Try: metho --gui --port ${port + 1}`);
|
|
241
|
+
} else {
|
|
242
|
+
console.error('Server error:', err);
|
|
243
|
+
}
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
server.listen(port, () => {
|
|
248
|
+
const url = `http://localhost:${port}`;
|
|
249
|
+
console.log(`\n Metho GUI running at ${url}\n`);
|
|
250
|
+
openBrowser(url);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return server;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function openBrowser(url) {
|
|
257
|
+
const cmd = process.platform === 'win32' ? `start "" "${url}"`
|
|
258
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
259
|
+
: `xdg-open "${url}"`;
|
|
260
|
+
exec(cmd, () => {});
|
|
261
|
+
}
|
package/src/signals.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
|
|
3
|
+
const isWin = process.platform === 'win32';
|
|
4
|
+
|
|
5
|
+
function forceKillChildren(children, logger) {
|
|
6
|
+
for (const child of children) {
|
|
7
|
+
const pid = child.pid;
|
|
8
|
+
if (!pid) continue;
|
|
9
|
+
if (logger) logger.debug(`Force-killing PID ${pid}`);
|
|
10
|
+
try {
|
|
11
|
+
if (isWin) {
|
|
12
|
+
exec(`taskkill /F /T /PID ${pid}`, () => {});
|
|
13
|
+
} else {
|
|
14
|
+
try { process.kill(-pid, 'SIGKILL'); } catch {}
|
|
15
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
children.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-run context — isolates children, interrupt flag, and tracked files for one pipeline run.
|
|
26
|
+
*/
|
|
27
|
+
export class RunContext {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.children = new Set();
|
|
30
|
+
this.interrupted = false;
|
|
31
|
+
this.files = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
registerChild(child) {
|
|
35
|
+
this.children.add(child);
|
|
36
|
+
child.on('close', () => this.children.delete(child));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clearChild(child) {
|
|
40
|
+
if (child) this.children.delete(child);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
trackFile(filePath, description) {
|
|
44
|
+
this.files.push({ path: filePath, description });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
isInterrupted() {
|
|
48
|
+
return this.interrupted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
triggerInterrupt(logger) {
|
|
52
|
+
if (this.interrupted) return;
|
|
53
|
+
this.interrupted = true;
|
|
54
|
+
if (logger) logger.warn('Stop requested. Killing active processes...');
|
|
55
|
+
forceKillChildren(this.children, logger);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a new isolated run context.
|
|
61
|
+
*/
|
|
62
|
+
export function createRunContext() {
|
|
63
|
+
return new RunContext();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- Global context (used by CLI mode + SIGINT/SIGTERM handler) ----
|
|
67
|
+
|
|
68
|
+
let globalCtx = new RunContext();
|
|
69
|
+
|
|
70
|
+
/** Set up process signal handlers that interrupt the global context. */
|
|
71
|
+
export function setupSignalHandlers(logger) {
|
|
72
|
+
const handler = (signal) => {
|
|
73
|
+
if (globalCtx.interrupted) {
|
|
74
|
+
logger.warn('Force exit.');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
globalCtx.interrupted = true;
|
|
78
|
+
logger.warn(`\n${signal} received. Stopping...`);
|
|
79
|
+
forceKillChildren(globalCtx.children, logger);
|
|
80
|
+
|
|
81
|
+
if (globalCtx.files.length > 0) {
|
|
82
|
+
logger.info('Partial results saved:');
|
|
83
|
+
for (const f of globalCtx.files) {
|
|
84
|
+
logger.info(` ${f.description}: ${f.path}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
process.on('SIGINT', () => handler('SIGINT'));
|
|
90
|
+
process.on('SIGTERM', () => handler('SIGTERM'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Get the global context (for CLI single-run mode). */
|
|
94
|
+
export function getGlobalContext() {
|
|
95
|
+
return globalCtx;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reset the global context (for CLI mode restarts). */
|
|
99
|
+
export function resetGlobalContext() {
|
|
100
|
+
globalCtx = new RunContext();
|
|
101
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createReadStream } from 'fs';
|
|
3
|
+
import { stat } from 'fs/promises';
|
|
4
|
+
|
|
5
|
+
const isWin = process.platform === 'win32';
|
|
6
|
+
|
|
7
|
+
export class BaseStep {
|
|
8
|
+
constructor(name, logger, ctx) {
|
|
9
|
+
this.name = name;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.ctx = ctx;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spawn a child process with streaming output.
|
|
16
|
+
* Returns a promise that resolves with { code, lineCount }.
|
|
17
|
+
*/
|
|
18
|
+
runCommand(cmd, args, options = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
if (this.ctx.isInterrupted()) {
|
|
21
|
+
return reject(new Error('Pipeline interrupted'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.logger.debug(`${this.name}: ${cmd} ${args.join(' ')}`);
|
|
25
|
+
|
|
26
|
+
const spawnOpts = {
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
shell: isWin,
|
|
29
|
+
};
|
|
30
|
+
if (options.cwd) spawnOpts.cwd = options.cwd;
|
|
31
|
+
|
|
32
|
+
const child = spawn(cmd, args, spawnOpts);
|
|
33
|
+
this.ctx.registerChild(child);
|
|
34
|
+
|
|
35
|
+
let stderrBuf = '';
|
|
36
|
+
|
|
37
|
+
child.stdout.on('data', (data) => {
|
|
38
|
+
const lines = data.toString().trim();
|
|
39
|
+
if (lines) this.logger.debug(`${this.name} stdout: ${lines}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.stderr.on('data', (data) => {
|
|
43
|
+
const text = data.toString().trim();
|
|
44
|
+
if (text) {
|
|
45
|
+
stderrBuf += text + '\n';
|
|
46
|
+
this.logger.debug(`${this.name} stderr: ${text}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Pipe stdin from file if requested
|
|
51
|
+
if (options.stdinFile) {
|
|
52
|
+
const stdinStream = createReadStream(options.stdinFile);
|
|
53
|
+
stdinStream.pipe(child.stdin);
|
|
54
|
+
stdinStream.on('error', (err) => {
|
|
55
|
+
this.logger.debug(`${this.name} stdin stream error: ${err.message}`);
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
child.stdin.end();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
child.on('error', (err) => {
|
|
62
|
+
this.ctx.clearChild(child);
|
|
63
|
+
reject(new Error(`${this.name}: Failed to spawn ${cmd}: ${err.message}`));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
child.on('close', async (code) => {
|
|
67
|
+
this.ctx.clearChild(child);
|
|
68
|
+
|
|
69
|
+
if (this.ctx.isInterrupted()) {
|
|
70
|
+
return reject(new Error('Pipeline interrupted'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let lineCount = 0;
|
|
74
|
+
if (options.outputFile) {
|
|
75
|
+
lineCount = await this.countLines(options.outputFile);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (code !== 0) {
|
|
79
|
+
if (lineCount > 0) {
|
|
80
|
+
this.logger.warn(`${this.name} exited with code ${code} but produced ${lineCount} results`);
|
|
81
|
+
return resolve({ code, lineCount });
|
|
82
|
+
}
|
|
83
|
+
return reject(new Error(`${this.name}: ${cmd} exited with code ${code}\n${stderrBuf}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
resolve({ code, lineCount });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async countLines(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
const stats = await stat(filePath);
|
|
94
|
+
if (stats.size === 0) return 0;
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let count = 0;
|
|
98
|
+
const stream = createReadStream(filePath, { encoding: 'utf-8' });
|
|
99
|
+
stream.on('data', (chunk) => {
|
|
100
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
101
|
+
if (chunk[i] === '\n') count++;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
stream.on('end', () => resolve(count));
|
|
105
|
+
stream.on('error', () => resolve(0));
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BaseStep } from './base-step.js';
|
|
2
|
+
import { getToolPath } from '../tools/manager.js';
|
|
3
|
+
import { filterExtensions } from '../utils/filter-extensions.js';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { HTTPX_STATUS_CODES } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
export class FilterStep extends BaseStep {
|
|
8
|
+
constructor(logger, ctx) {
|
|
9
|
+
super('filter', logger, ctx);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async execute(config, inputFile, outputFile) {
|
|
13
|
+
const filteredTmpFile = join(dirname(outputFile), 'tmp-extension-filtered.txt');
|
|
14
|
+
this.logger.debug('Filter Phase A: Removing blocked extensions...');
|
|
15
|
+
|
|
16
|
+
const { kept, removed } = await filterExtensions(inputFile, filteredTmpFile);
|
|
17
|
+
this.logger.info(`Extension filter: kept ${kept}, removed ${removed} URLs`);
|
|
18
|
+
|
|
19
|
+
if (kept === 0) {
|
|
20
|
+
this.logger.warn('No URLs remaining after extension filtering');
|
|
21
|
+
const { writeFileSync } = await import('fs');
|
|
22
|
+
writeFileSync(outputFile, '');
|
|
23
|
+
return { code: 0, lineCount: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.logger.debug('Filter Phase B: httpx liveness check...');
|
|
27
|
+
const args = [
|
|
28
|
+
'-l', filteredTmpFile,
|
|
29
|
+
'-o', outputFile,
|
|
30
|
+
'-mc', HTTPX_STATUS_CODES,
|
|
31
|
+
'-silent',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const result = await this.runCommand(getToolPath('httpx'), args, { outputFile });
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { unlinkSync } = await import('fs');
|
|
38
|
+
unlinkSync(filteredTmpFile);
|
|
39
|
+
} catch { /* ignore */ }
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BaseStep } from './base-step.js';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const isWin = process.platform === 'win32';
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
// Bundled script path: metho/scripts/findsomething_secrets.py
|
|
10
|
+
const BUNDLED_SCRIPT = join(__dirname, '..', '..', 'scripts', 'findsomething_secrets.py');
|
|
11
|
+
|
|
12
|
+
export class FindSomethingStep extends BaseStep {
|
|
13
|
+
constructor(logger, ctx) {
|
|
14
|
+
super('findsomething', logger, ctx);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async execute(config, inputFile, outputFile) {
|
|
18
|
+
// Priority: explicit config > env var > bundled script
|
|
19
|
+
let scriptPath = config.findsomethingPath;
|
|
20
|
+
if (!scriptPath || !existsSync(scriptPath)) {
|
|
21
|
+
scriptPath = process.env.FINDSOMETHING_PATH;
|
|
22
|
+
}
|
|
23
|
+
if (!scriptPath || !existsSync(scriptPath)) {
|
|
24
|
+
scriptPath = BUNDLED_SCRIPT;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!existsSync(scriptPath)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`FindSomething script not found.\n` +
|
|
30
|
+
`Checked: ${BUNDLED_SCRIPT}\n` +
|
|
31
|
+
`Set the path with --findsomething-path <path> or FINDSOMETHING_PATH env var`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.logger.debug(`Using findsomething script: ${scriptPath}`);
|
|
36
|
+
const pythonBin = isWin ? 'python' : 'python3';
|
|
37
|
+
const args = [scriptPath, '-l', inputFile, '-o', outputFile];
|
|
38
|
+
|
|
39
|
+
const result = await this.runCommand(pythonBin, args, { outputFile });
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
}
|