jsana 1.0.1 → 1.0.3
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/jsana.js +6 -0
- package/lib/pipeline.js +115 -27
- package/lib/progress.js +9 -4
- package/lib/reporter.js +2 -2
- package/package.json +1 -1
package/bin/jsana.js
CHANGED
|
@@ -16,6 +16,7 @@ Options:
|
|
|
16
16
|
-c, --concurrency <n> Concurrent fetches (default: 50)
|
|
17
17
|
-r, --retries <n> Max retries per URL (default: 2)
|
|
18
18
|
-t, --timeout <ms> Request timeout in ms (default: 30000)
|
|
19
|
+
--resume Resume a previous interrupted scan
|
|
19
20
|
--category <name> Filter to specific categories (repeatable)
|
|
20
21
|
-h, --help Show this help
|
|
21
22
|
|
|
@@ -25,6 +26,7 @@ Categories:
|
|
|
25
26
|
Examples:
|
|
26
27
|
jsana urls.txt
|
|
27
28
|
jsana urls.txt -o results.txt -c 100 --json
|
|
29
|
+
jsana urls.txt --resume -o results.txt # resume interrupted scan
|
|
28
30
|
jsana urls.txt --category secret --category xss-sink
|
|
29
31
|
`;
|
|
30
32
|
|
|
@@ -37,6 +39,7 @@ function parseArgs(argv) {
|
|
|
37
39
|
retries: 2,
|
|
38
40
|
timeout: 30000,
|
|
39
41
|
categories: [],
|
|
42
|
+
resume: false,
|
|
40
43
|
help: false,
|
|
41
44
|
};
|
|
42
45
|
|
|
@@ -56,6 +59,8 @@ function parseArgs(argv) {
|
|
|
56
59
|
args.retries = parseInt(argv[++i], 10); break;
|
|
57
60
|
case '-t': case '--timeout':
|
|
58
61
|
args.timeout = parseInt(argv[++i], 10); break;
|
|
62
|
+
case '--resume':
|
|
63
|
+
args.resume = true; break;
|
|
59
64
|
case '--category':
|
|
60
65
|
args.categories.push(argv[++i]); break;
|
|
61
66
|
default:
|
|
@@ -93,6 +98,7 @@ async function main() {
|
|
|
93
98
|
retries: args.retries,
|
|
94
99
|
timeout: args.timeout,
|
|
95
100
|
categories: args.categories,
|
|
101
|
+
resume: args.resume,
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
|
package/lib/pipeline.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
1
|
+
import { createReadStream, createWriteStream, existsSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { fetchUrl, closeAllPools } from './fetcher.js';
|
|
4
4
|
import { scan } from './scanner.js';
|
|
@@ -7,7 +7,21 @@ import { Progress, countLines } from './progress.js';
|
|
|
7
7
|
import { getPatterns } from './patterns.js';
|
|
8
8
|
import { withRetry } from './retry.js';
|
|
9
9
|
|
|
10
|
-
//
|
|
10
|
+
// Load already-processed URLs from the progress file
|
|
11
|
+
async function loadDoneSet(progressFile) {
|
|
12
|
+
const done = new Set();
|
|
13
|
+
if (!existsSync(progressFile)) return done;
|
|
14
|
+
const rl = createInterface({
|
|
15
|
+
input: createReadStream(progressFile, { encoding: 'utf8' }),
|
|
16
|
+
crlfDelay: Infinity,
|
|
17
|
+
});
|
|
18
|
+
for await (const line of rl) {
|
|
19
|
+
if (line) done.add(line);
|
|
20
|
+
}
|
|
21
|
+
return done;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Simple semaphore for concurrency control, with abort support
|
|
11
25
|
class Semaphore {
|
|
12
26
|
constructor(max) {
|
|
13
27
|
this.max = max;
|
|
@@ -19,14 +33,23 @@ class Semaphore {
|
|
|
19
33
|
this.current++;
|
|
20
34
|
return Promise.resolve();
|
|
21
35
|
}
|
|
22
|
-
return new Promise(resolve =>
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
this.queue.push({ resolve, reject });
|
|
38
|
+
});
|
|
23
39
|
}
|
|
24
40
|
release() {
|
|
25
41
|
this.current--;
|
|
26
42
|
if (this.queue.length > 0) {
|
|
27
43
|
this.current++;
|
|
28
|
-
this.queue.shift()();
|
|
44
|
+
this.queue.shift().resolve();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Reject all waiters so they unblock immediately
|
|
48
|
+
abortAll() {
|
|
49
|
+
for (const { reject } of this.queue) {
|
|
50
|
+
reject(new Error('aborted'));
|
|
29
51
|
}
|
|
52
|
+
this.queue = [];
|
|
30
53
|
}
|
|
31
54
|
}
|
|
32
55
|
|
|
@@ -38,6 +61,7 @@ export async function run(urlsFile, opts) {
|
|
|
38
61
|
retries = 2,
|
|
39
62
|
timeout = 30000,
|
|
40
63
|
categories = [],
|
|
64
|
+
resume = false,
|
|
41
65
|
} = opts;
|
|
42
66
|
|
|
43
67
|
const patterns = getPatterns(categories);
|
|
@@ -46,30 +70,75 @@ export async function run(urlsFile, opts) {
|
|
|
46
70
|
return;
|
|
47
71
|
}
|
|
48
72
|
|
|
73
|
+
// Resume support: progress file tracks processed URLs
|
|
74
|
+
const progressFile = output + '.progress';
|
|
75
|
+
let doneSet = new Set();
|
|
76
|
+
if (resume) {
|
|
77
|
+
doneSet = await loadDoneSet(progressFile);
|
|
78
|
+
if (doneSet.size > 0) {
|
|
79
|
+
process.stderr.write(`[jsana] Resuming — ${doneSet.size} URLs already processed, skipping.\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const progressLog = createWriteStream(progressFile, { flags: resume ? 'a' : 'w', encoding: 'utf8' });
|
|
83
|
+
|
|
49
84
|
// Pre-count lines for progress
|
|
50
85
|
const totalLines = await countLines(urlsFile);
|
|
51
86
|
const progress = new Progress(totalLines);
|
|
52
|
-
const reporter = new Reporter(output, { json });
|
|
87
|
+
const reporter = new Reporter(output, { json, append: resume });
|
|
53
88
|
const sem = new Semaphore(concurrency);
|
|
54
89
|
|
|
55
90
|
let shuttingDown = false;
|
|
91
|
+
let forceExit = false;
|
|
56
92
|
const inFlight = new Set();
|
|
93
|
+
const abortController = new AbortController();
|
|
94
|
+
|
|
95
|
+
const fileStream = createReadStream(urlsFile, { encoding: 'utf8' });
|
|
96
|
+
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
57
97
|
|
|
58
|
-
// Graceful shutdown
|
|
59
|
-
const shutdown = () => {
|
|
60
|
-
if (
|
|
98
|
+
// Graceful shutdown: 1st Ctrl+C stops new work, 2nd force exits
|
|
99
|
+
const shutdown = async () => {
|
|
100
|
+
if (forceExit) {
|
|
101
|
+
process.stderr.write('\n[jsana] Force exit.\n');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (shuttingDown) {
|
|
105
|
+
forceExit = true;
|
|
106
|
+
process.stderr.write('\n[jsana] Press Ctrl+C again to force exit.\n');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
61
109
|
shuttingDown = true;
|
|
62
|
-
|
|
110
|
+
abortController.abort();
|
|
111
|
+
process.stderr.write('\n[jsana] Interrupted. Waiting for in-flight requests to finish...\n');
|
|
112
|
+
|
|
113
|
+
// Stop reading new URLs
|
|
114
|
+
rl.close();
|
|
115
|
+
fileStream.destroy();
|
|
116
|
+
|
|
117
|
+
// Unblock anything waiting on the semaphore
|
|
118
|
+
sem.abortAll();
|
|
119
|
+
|
|
120
|
+
// Wait for in-flight tasks (with a 5s timeout)
|
|
121
|
+
const deadline = setTimeout(() => {}, 5000);
|
|
122
|
+
await Promise.allSettled([...inFlight]);
|
|
123
|
+
clearTimeout(deadline);
|
|
124
|
+
|
|
125
|
+
progress.stop();
|
|
126
|
+
progressLog.end();
|
|
127
|
+
await reporter.close();
|
|
128
|
+
await closeAllPools();
|
|
129
|
+
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[jsana] Stopped. ${progress.processed} URLs processed, ` +
|
|
132
|
+
`${reporter.count} findings, ${progress.errors} errors. ` +
|
|
133
|
+
`Output: ${output}\n` +
|
|
134
|
+
`[jsana] Resume with: jsana ${urlsFile} --resume -o ${output}\n`
|
|
135
|
+
);
|
|
136
|
+
process.exit(0);
|
|
63
137
|
};
|
|
64
138
|
process.on('SIGINT', shutdown);
|
|
65
139
|
|
|
66
140
|
progress.start();
|
|
67
141
|
|
|
68
|
-
const rl = createInterface({
|
|
69
|
-
input: createReadStream(urlsFile, { encoding: 'utf8' }),
|
|
70
|
-
crlfDelay: Infinity,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
142
|
for await (const rawLine of rl) {
|
|
74
143
|
if (shuttingDown) break;
|
|
75
144
|
|
|
@@ -91,11 +160,23 @@ export async function run(urlsFile, opts) {
|
|
|
91
160
|
continue;
|
|
92
161
|
}
|
|
93
162
|
|
|
94
|
-
|
|
163
|
+
// Skip already-processed URLs on resume
|
|
164
|
+
if (doneSet.has(url.href)) {
|
|
165
|
+
progress.skip();
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await sem.acquire();
|
|
171
|
+
} catch {
|
|
172
|
+
break; // aborted
|
|
173
|
+
}
|
|
95
174
|
if (shuttingDown) { sem.release(); break; }
|
|
96
175
|
|
|
97
176
|
const task = (async () => {
|
|
98
177
|
try {
|
|
178
|
+
if (shuttingDown) return;
|
|
179
|
+
|
|
99
180
|
const stream = await withRetry(
|
|
100
181
|
() => fetchUrl(url.href, { timeout }),
|
|
101
182
|
{ maxRetries: retries }
|
|
@@ -114,12 +195,12 @@ export async function run(urlsFile, opts) {
|
|
|
114
195
|
}
|
|
115
196
|
} catch {
|
|
116
197
|
// Stream error mid-scan (e.g. response too large, connection reset)
|
|
117
|
-
// Already counted as error below if fetch itself threw
|
|
118
198
|
}
|
|
119
199
|
progress.addFindings(findingsInUrl);
|
|
120
200
|
} catch (err) {
|
|
121
201
|
progress.addError();
|
|
122
202
|
} finally {
|
|
203
|
+
progressLog.write(url.href + '\n');
|
|
123
204
|
progress.tick();
|
|
124
205
|
sem.release();
|
|
125
206
|
}
|
|
@@ -129,18 +210,25 @@ export async function run(urlsFile, opts) {
|
|
|
129
210
|
task.finally(() => inFlight.delete(task));
|
|
130
211
|
}
|
|
131
212
|
|
|
132
|
-
|
|
133
|
-
|
|
213
|
+
if (!shuttingDown) {
|
|
214
|
+
// Normal completion (not interrupted)
|
|
215
|
+
await Promise.allSettled([...inFlight]);
|
|
216
|
+
|
|
217
|
+
progress.stop();
|
|
218
|
+
progressLog.end();
|
|
219
|
+
await reporter.close();
|
|
220
|
+
await closeAllPools();
|
|
134
221
|
|
|
135
|
-
|
|
136
|
-
await reporter.close();
|
|
137
|
-
await closeAllPools();
|
|
222
|
+
process.removeListener('SIGINT', shutdown);
|
|
138
223
|
|
|
139
|
-
|
|
224
|
+
// Clean up progress file on successful completion
|
|
225
|
+
const { unlink } = await import('node:fs/promises');
|
|
226
|
+
await unlink(progressFile).catch(() => {});
|
|
140
227
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
228
|
+
process.stderr.write(
|
|
229
|
+
`[jsana] Done. ${progress.processed} URLs processed, ` +
|
|
230
|
+
`${reporter.count} findings, ${progress.errors} errors. ` +
|
|
231
|
+
`Output: ${output}\n`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
146
234
|
}
|
package/lib/progress.js
CHANGED
|
@@ -20,6 +20,7 @@ export class Progress {
|
|
|
20
20
|
constructor(total) {
|
|
21
21
|
this.total = total;
|
|
22
22
|
this.processed = 0;
|
|
23
|
+
this.skipped = 0;
|
|
23
24
|
this.findings = 0;
|
|
24
25
|
this.errors = 0;
|
|
25
26
|
this.startTime = Date.now();
|
|
@@ -32,15 +33,19 @@ export class Progress {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
tick() { this.processed++; }
|
|
36
|
+
skip() { this.skipped++; this.processed++; }
|
|
35
37
|
addFindings(n) { this.findings += n; }
|
|
36
38
|
addError() { this.errors++; }
|
|
37
39
|
|
|
38
40
|
_render() {
|
|
39
41
|
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
42
|
+
const active = this.processed - this.skipped;
|
|
43
|
+
const rate = active > 0 ? (active / (elapsed || 1)).toFixed(1) : '0.0';
|
|
44
|
+
const pct = Math.min(100, (this.processed / this.total) * 100).toFixed(1);
|
|
45
|
+
let line = `\r[jsana] ${this.processed}/${this.total} (${pct}%) | findings: ${this.findings} | errors: ${this.errors} | ${rate} URLs/s | ${elapsed}s`;
|
|
46
|
+
if (this.skipped > 0) line += ` | resumed: ${this.skipped} skipped`;
|
|
47
|
+
// Clear any leftover chars from previous longer line
|
|
48
|
+
process.stderr.write(line + ' ');
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
stop() {
|
package/lib/reporter.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createWriteStream } from 'node:fs';
|
|
2
2
|
|
|
3
3
|
export class Reporter {
|
|
4
|
-
constructor(outputPath, { json = false } = {}) {
|
|
4
|
+
constructor(outputPath, { json = false, append = false } = {}) {
|
|
5
5
|
this.json = json;
|
|
6
|
-
this.stream = createWriteStream(outputPath, { flags: 'w', encoding: 'utf8' });
|
|
6
|
+
this.stream = createWriteStream(outputPath, { flags: append ? 'a' : 'w', encoding: 'utf8' });
|
|
7
7
|
this.count = 0;
|
|
8
8
|
}
|
|
9
9
|
|