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 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
- // Simple semaphore for concurrency control
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 => this.queue.push(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 (shuttingDown) return;
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
- process.stderr.write('\n[jsana] Shutting down gracefully...\n');
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
- await sem.acquire();
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
- // Wait for all in-flight tasks
133
- await Promise.allSettled([...inFlight]);
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
- progress.stop();
136
- await reporter.close();
137
- await closeAllPools();
222
+ process.removeListener('SIGINT', shutdown);
138
223
 
139
- process.removeListener('SIGINT', shutdown);
224
+ // Clean up progress file on successful completion
225
+ const { unlink } = await import('node:fs/promises');
226
+ await unlink(progressFile).catch(() => {});
140
227
 
141
- process.stderr.write(
142
- `[jsana] Done. ${progress.processed} URLs processed, ` +
143
- `${reporter.count} findings, ${progress.errors} errors. ` +
144
- `Output: ${output}\n`
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 rate = this.processed > 0 ? (this.processed / (elapsed || 1)).toFixed(1) : '0.0';
41
- const pct = ((this.processed / this.total) * 100).toFixed(1);
42
- const line = `\r[jsana] ${this.processed}/${this.total} (${pct}%) | findings: ${this.findings} | errors: ${this.errors} | ${rate} URLs/s | ${elapsed}s`;
43
- process.stderr.write(line);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsana",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "JavaScript Security Analyzer for Bug Bounty",
5
5
  "type": "module",
6
6
  "license": "MIT",