cipher-security 2.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.
Files changed (76) hide show
  1. package/bin/cipher.js +566 -0
  2. package/lib/api/billing.js +321 -0
  3. package/lib/api/compliance.js +693 -0
  4. package/lib/api/controls.js +1401 -0
  5. package/lib/api/index.js +49 -0
  6. package/lib/api/marketplace.js +467 -0
  7. package/lib/api/openai-proxy.js +383 -0
  8. package/lib/api/server.js +685 -0
  9. package/lib/autonomous/feedback-loop.js +554 -0
  10. package/lib/autonomous/framework.js +512 -0
  11. package/lib/autonomous/index.js +97 -0
  12. package/lib/autonomous/leaderboard.js +594 -0
  13. package/lib/autonomous/modes/architect.js +412 -0
  14. package/lib/autonomous/modes/blue.js +386 -0
  15. package/lib/autonomous/modes/incident.js +684 -0
  16. package/lib/autonomous/modes/privacy.js +369 -0
  17. package/lib/autonomous/modes/purple.js +294 -0
  18. package/lib/autonomous/modes/recon.js +250 -0
  19. package/lib/autonomous/parallel.js +587 -0
  20. package/lib/autonomous/researcher.js +583 -0
  21. package/lib/autonomous/runner.js +955 -0
  22. package/lib/autonomous/scheduler.js +615 -0
  23. package/lib/autonomous/task-parser.js +127 -0
  24. package/lib/autonomous/validators/forensic.js +266 -0
  25. package/lib/autonomous/validators/osint.js +216 -0
  26. package/lib/autonomous/validators/privacy.js +296 -0
  27. package/lib/autonomous/validators/purple.js +298 -0
  28. package/lib/autonomous/validators/sigma.js +248 -0
  29. package/lib/autonomous/validators/threat-model.js +363 -0
  30. package/lib/benchmark/agent.js +119 -0
  31. package/lib/benchmark/baselines.js +43 -0
  32. package/lib/benchmark/builder.js +143 -0
  33. package/lib/benchmark/config.js +35 -0
  34. package/lib/benchmark/coordinator.js +91 -0
  35. package/lib/benchmark/index.js +20 -0
  36. package/lib/benchmark/llm.js +58 -0
  37. package/lib/benchmark/models.js +137 -0
  38. package/lib/benchmark/reporter.js +103 -0
  39. package/lib/benchmark/runner.js +103 -0
  40. package/lib/benchmark/sandbox.js +96 -0
  41. package/lib/benchmark/scorer.js +32 -0
  42. package/lib/benchmark/solver.js +166 -0
  43. package/lib/benchmark/tools.js +62 -0
  44. package/lib/bot/bot.js +238 -0
  45. package/lib/brand.js +105 -0
  46. package/lib/commands.js +100 -0
  47. package/lib/complexity.js +377 -0
  48. package/lib/config.js +213 -0
  49. package/lib/gateway/client.js +309 -0
  50. package/lib/gateway/commands.js +991 -0
  51. package/lib/gateway/config-validate.js +109 -0
  52. package/lib/gateway/gateway.js +367 -0
  53. package/lib/gateway/index.js +62 -0
  54. package/lib/gateway/mode.js +309 -0
  55. package/lib/gateway/plugins.js +222 -0
  56. package/lib/gateway/prompt.js +214 -0
  57. package/lib/mcp/server.js +262 -0
  58. package/lib/memory/compressor.js +425 -0
  59. package/lib/memory/engine.js +763 -0
  60. package/lib/memory/evolution.js +668 -0
  61. package/lib/memory/index.js +58 -0
  62. package/lib/memory/orchestrator.js +506 -0
  63. package/lib/memory/retriever.js +515 -0
  64. package/lib/memory/synthesizer.js +333 -0
  65. package/lib/pipeline/async-scanner.js +510 -0
  66. package/lib/pipeline/binary-analysis.js +1043 -0
  67. package/lib/pipeline/dom-xss-scanner.js +435 -0
  68. package/lib/pipeline/github-actions.js +792 -0
  69. package/lib/pipeline/index.js +124 -0
  70. package/lib/pipeline/osint.js +498 -0
  71. package/lib/pipeline/sarif.js +373 -0
  72. package/lib/pipeline/scanner.js +880 -0
  73. package/lib/pipeline/template-manager.js +525 -0
  74. package/lib/pipeline/xss-scanner.js +353 -0
  75. package/lib/setup-wizard.js +288 -0
  76. package/package.json +31 -0
@@ -0,0 +1,880 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * CIPHER Scanning Pipeline — Nuclei/Katana integration layer.
7
+ *
8
+ * Defines all shared data classes (Finding, ScanResult, CrawlResult,
9
+ * PipelineResult, ScanProfile, ScanDomain) and the core subprocess
10
+ * spawning pattern for the pipeline module. Every other pipeline module
11
+ * references these types.
12
+ *
13
+ * Ported from pipeline/scanner.py (845 LOC Python).
14
+ */
15
+
16
+ import { spawn, execFileSync } from 'node:child_process';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const DEFAULT_TIMEOUT = 600; // 10 minutes
23
+ const TOOL_MISSING = (tool, path) =>
24
+ `${tool} not found at '${path}'. Install: https://github.com/projectdiscovery/${tool}#installation`;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Enums
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** @enum {string} CIPHER operating domains mapped to scan profiles. */
31
+ const ScanDomain = Object.freeze({
32
+ RED: 'red',
33
+ BLUE: 'blue',
34
+ RECON: 'recon',
35
+ PRIVACY: 'privacy',
36
+ ARCHITECT: 'architect',
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Profile configs
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const PROFILE_CONFIGS = Object.freeze({
44
+ red: {
45
+ name: 'red-team',
46
+ tags: ['cve', 'rce', 'sqli', 'xss', 'ssrf', 'lfi', 'auth-bypass'],
47
+ severity: ['critical', 'high', 'medium'],
48
+ rateLimit: 100,
49
+ bulkSize: 15,
50
+ headless: true,
51
+ },
52
+ blue: {
53
+ name: 'hardening-audit',
54
+ tags: ['misconfig', 'exposure', 'default-login', 'unauth'],
55
+ severity: ['critical', 'high', 'medium', 'low'],
56
+ rateLimit: 200,
57
+ bulkSize: 30,
58
+ headless: false,
59
+ },
60
+ recon: {
61
+ name: 'recon-sweep',
62
+ tags: ['tech', 'token', 'exposure', 'dns'],
63
+ severity: ['info', 'low', 'medium'],
64
+ rateLimit: 250,
65
+ bulkSize: 50,
66
+ headless: false,
67
+ },
68
+ privacy: {
69
+ name: 'privacy-audit',
70
+ tags: ['exposure', 'token', 'misconfig', 'unauth'],
71
+ severity: ['critical', 'high', 'medium'],
72
+ rateLimit: 150,
73
+ bulkSize: 25,
74
+ headless: false,
75
+ },
76
+ pentest: {
77
+ name: 'pentest',
78
+ tags: ['cve', 'rce', 'sqli', 'xss', 'ssrf', 'misconfig'],
79
+ severity: ['critical', 'high', 'medium'],
80
+ rateLimit: 150,
81
+ bulkSize: 25,
82
+ headless: false,
83
+ },
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Data classes
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Single vulnerability finding from Nuclei.
92
+ */
93
+ class Finding {
94
+ constructor(opts = {}) {
95
+ this.templateId = opts.templateId ?? '';
96
+ this.name = opts.name ?? '';
97
+ this.severity = opts.severity ?? '';
98
+ this.host = opts.host ?? '';
99
+ this.matchedAt = opts.matchedAt ?? '';
100
+ this.matcherName = opts.matcherName ?? '';
101
+ this.description = opts.description ?? '';
102
+ this.reference = opts.reference ?? [];
103
+ this.tags = opts.tags ?? [];
104
+ this.cveIds = opts.cveIds ?? [];
105
+ this.cweIds = opts.cweIds ?? [];
106
+ this.raw = opts.raw ?? {};
107
+ }
108
+
109
+ /**
110
+ * Parse a single Nuclei JSONL object.
111
+ * @param {object} data - Parsed JSON object from Nuclei output
112
+ * @returns {Finding}
113
+ */
114
+ static fromNucleiJson(data) {
115
+ const info = data.info ?? {};
116
+ const classif = info.classification ?? {};
117
+ return new Finding({
118
+ templateId: data['template-id'] ?? data.template_id ?? '',
119
+ name: info.name ?? '',
120
+ severity: info.severity ?? 'unknown',
121
+ host: data.host ?? '',
122
+ matchedAt: data['matched-at'] ?? data.matched_at ?? '',
123
+ matcherName: data['matcher-name'] ?? data.matcher_name ?? '',
124
+ description: info.description ?? '',
125
+ reference: info.reference ?? [],
126
+ tags: info.tags ?? [],
127
+ cveIds: classif['cve-id'] ?? [],
128
+ cweIds: (classif['cwe-id'] ?? []).map(String),
129
+ raw: data,
130
+ });
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Aggregated result from a Nuclei scan.
136
+ */
137
+ class ScanResult {
138
+ constructor(opts = {}) {
139
+ this.target = opts.target ?? '';
140
+ this.findings = opts.findings ?? [];
141
+ this.stats = opts.stats ?? {};
142
+ this.errors = opts.errors ?? [];
143
+ this.command = opts.command ?? '';
144
+ this.durationSeconds = opts.durationSeconds ?? 0;
145
+ this.success = opts.success ?? false;
146
+ }
147
+
148
+ get criticalCount() {
149
+ return this.findings.filter(f => f.severity === 'critical').length;
150
+ }
151
+
152
+ get highCount() {
153
+ return this.findings.filter(f => f.severity === 'high').length;
154
+ }
155
+
156
+ /**
157
+ * Export scan results as SARIF v2.1.0 JSON string.
158
+ * Lazy-imports ./sarif.js — may not exist until T03.
159
+ * @returns {Promise<string>}
160
+ */
161
+ async toSarif() {
162
+ const { SarifReport } = await import('./sarif.js');
163
+ const report = new SarifReport();
164
+ for (const f of this.findings) {
165
+ report.addFinding({
166
+ templateId: f.templateId,
167
+ name: f.name,
168
+ description: f.description,
169
+ severity: f.severity,
170
+ host: f.host,
171
+ matchedAt: f.matchedAt,
172
+ tags: f.tags,
173
+ cveIds: f.cveIds,
174
+ reference: f.reference,
175
+ });
176
+ }
177
+ return report.toJson();
178
+ }
179
+
180
+ /**
181
+ * Auto-generate nuclei templates from critical/high findings.
182
+ * Lazy-imports ./template-manager.js — may not exist until T03.
183
+ * @returns {Promise<string[]>}
184
+ */
185
+ async generateCustomTemplates() {
186
+ const { NucleiTemplateManager } = await import('./template-manager.js');
187
+ const mgr = new NucleiTemplateManager();
188
+ const templates = [];
189
+ for (const f of this.findings) {
190
+ if (f.severity === 'critical' || f.severity === 'high') {
191
+ let path = '/';
192
+ if (f.matchedAt && f.matchedAt.includes('//')) {
193
+ const afterScheme = f.matchedAt.split('//').slice(1).join('//');
194
+ const parts = afterScheme.split('/');
195
+ path = parts.length > 1 ? '/' + parts.slice(1).join('/') : '/';
196
+ }
197
+ const templateYaml = mgr.generateTemplate({
198
+ title: f.name,
199
+ description: f.description,
200
+ severity: f.severity,
201
+ path,
202
+ matchers: f.name ? [f.name] : [],
203
+ });
204
+ templates.push(templateYaml);
205
+ }
206
+ }
207
+ return templates;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Aggregated result from a Katana crawl.
213
+ */
214
+ class CrawlResult {
215
+ constructor(opts = {}) {
216
+ this.target = opts.target ?? '';
217
+ this.urls = opts.urls ?? [];
218
+ this.endpoints = opts.endpoints ?? [];
219
+ this.forms = opts.forms ?? [];
220
+ this.jsFiles = opts.jsFiles ?? [];
221
+ this.errors = opts.errors ?? [];
222
+ this.command = opts.command ?? '';
223
+ this.success = opts.success ?? false;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Combined result from a full scan pipeline run.
229
+ */
230
+ class PipelineResult {
231
+ constructor(opts = {}) {
232
+ this.target = opts.target ?? '';
233
+ this.crawl = opts.crawl ?? null;
234
+ this.scan = opts.scan ?? null;
235
+ this.storedEntryIds = opts.storedEntryIds ?? [];
236
+ this.errors = opts.errors ?? [];
237
+ this.success = opts.success ?? false;
238
+ }
239
+
240
+ get urlsCrawled() {
241
+ return this.crawl ? this.crawl.urls.length : 0;
242
+ }
243
+
244
+ get findingsCount() {
245
+ return this.scan ? this.scan.findings.length : 0;
246
+ }
247
+
248
+ get findingsStored() {
249
+ return this.storedEntryIds.length;
250
+ }
251
+
252
+ get duration() {
253
+ return this.scan ? this.scan.durationSeconds : 0;
254
+ }
255
+
256
+ get findings() {
257
+ return this.scan ? this.scan.findings : [];
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // ScanProfile
263
+ // ---------------------------------------------------------------------------
264
+
265
+ class ScanProfile {
266
+ constructor(opts = {}) {
267
+ this.name = opts.name ?? 'pentest';
268
+ this.tags = opts.tags ?? [];
269
+ this.severity = opts.severity ?? [];
270
+ this.rateLimit = opts.rateLimit ?? 150;
271
+ this.bulkSize = opts.bulkSize ?? 25;
272
+ this.headless = opts.headless ?? false;
273
+ this.extraArgs = opts.extraArgs ?? [];
274
+ }
275
+
276
+ /**
277
+ * Build a ScanProfile from a CIPHER domain name.
278
+ * Falls back to 'pentest' profile for unknown domains.
279
+ * @param {string} domain
280
+ * @returns {ScanProfile}
281
+ */
282
+ static fromDomain(domain) {
283
+ const key = (domain ?? '').toLowerCase().trim();
284
+ const cfg = PROFILE_CONFIGS[key] ?? PROFILE_CONFIGS.pentest;
285
+ return new ScanProfile({
286
+ name: cfg.name,
287
+ tags: [...cfg.tags],
288
+ severity: [...cfg.severity],
289
+ rateLimit: cfg.rateLimit,
290
+ bulkSize: cfg.bulkSize,
291
+ headless: cfg.headless,
292
+ });
293
+ }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // NucleiRunner
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * Check if a binary is available on PATH.
302
+ * @param {string} binary
303
+ * @returns {string|null} Resolved path or null
304
+ */
305
+ function whichSync(binary) {
306
+ try {
307
+ return execFileSync('which', [binary], { encoding: 'utf8' }).trim() || null;
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ class NucleiRunner {
314
+ /**
315
+ * @param {object} opts
316
+ * @param {string} [opts.nucleiPath='nuclei']
317
+ * @param {string} [opts.templatesDir='']
318
+ * @param {string} [opts.outputDir='']
319
+ */
320
+ constructor(opts = {}) {
321
+ this.nucleiPath = opts.nucleiPath ?? 'nuclei';
322
+ this.templatesDir = opts.templatesDir ?? '';
323
+ this.outputDir = opts.outputDir ?? '';
324
+ this._resolved = whichSync(this.nucleiPath);
325
+ }
326
+
327
+ get available() {
328
+ return this._resolved !== null;
329
+ }
330
+
331
+ _fail(target) {
332
+ return new ScanResult({
333
+ target,
334
+ success: false,
335
+ errors: [TOOL_MISSING('nuclei', this.nucleiPath)],
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Run Nuclei against a target using a named scan profile.
341
+ * @param {string} target
342
+ * @param {object} opts
343
+ * @param {string|ScanProfile} [opts.profile='pentest']
344
+ * @param {string[]} [opts.tags]
345
+ * @param {string[]} [opts.severity]
346
+ * @param {number} [opts.timeout=600]
347
+ * @returns {Promise<ScanResult>}
348
+ */
349
+ scan(target, opts = {}) {
350
+ if (!this.available) return Promise.resolve(this._fail(target));
351
+
352
+ const profile = opts.profile ?? 'pentest';
353
+ const sp = typeof profile === 'string' ? ScanProfile.fromDomain(profile) : profile;
354
+ const effTags = [...new Set([...(opts.tags ?? []), ...sp.tags])];
355
+ const effSev = opts.severity ?? sp.severity;
356
+
357
+ const bin = this._resolved || this.nucleiPath;
358
+ const cmd = [bin, '-jsonl', '-silent'];
359
+ if (this.templatesDir) cmd.push('-t', this.templatesDir);
360
+ cmd.push('-u', target);
361
+ if (effTags.length) cmd.push('-tags', effTags.join(','));
362
+ if (effSev.length) cmd.push('-severity', effSev.join(','));
363
+ cmd.push('-rl', String(sp.rateLimit), '-bs', String(sp.bulkSize));
364
+ if (sp.headless) cmd.push('-headless');
365
+
366
+ return this._execute(cmd, target, opts.timeout ?? DEFAULT_TIMEOUT);
367
+ }
368
+
369
+ /**
370
+ * Run Nuclei with explicit template paths.
371
+ * @param {string} target
372
+ * @param {object} opts
373
+ * @param {string[]} [opts.templatePaths]
374
+ * @param {number} [opts.timeout=600]
375
+ * @returns {Promise<ScanResult>}
376
+ */
377
+ scanWithTemplates(target, opts = {}) {
378
+ if (!this.available) return Promise.resolve(this._fail(target));
379
+ const bin = this._resolved || this.nucleiPath;
380
+ const cmd = [bin, '-jsonl', '-silent', '-u', target];
381
+ for (const tp of opts.templatePaths ?? []) {
382
+ cmd.push('-t', tp);
383
+ }
384
+ return this._execute(cmd, target, opts.timeout ?? DEFAULT_TIMEOUT);
385
+ }
386
+
387
+ /**
388
+ * Enumerate available Nuclei templates.
389
+ * @param {object} [opts]
390
+ * @param {string[]} [opts.tags]
391
+ * @param {string[]} [opts.severity]
392
+ * @returns {object[]}
393
+ */
394
+ listTemplates(opts = {}) {
395
+ if (!this.available) return [];
396
+ const bin = this._resolved || this.nucleiPath;
397
+ const cmd = [bin, '-tl', '-jsonl'];
398
+ if (opts.tags?.length) cmd.push('-tags', opts.tags.join(','));
399
+ if (opts.severity?.length) cmd.push('-severity', opts.severity.join(','));
400
+ try {
401
+ const out = execFileSync(cmd[0], cmd.slice(1), {
402
+ encoding: 'utf8',
403
+ timeout: 120_000,
404
+ });
405
+ const results = [];
406
+ for (const line of out.trim().split('\n')) {
407
+ const trimmed = line.trim();
408
+ if (!trimmed) continue;
409
+ if (trimmed.startsWith('{')) {
410
+ try {
411
+ results.push(JSON.parse(trimmed));
412
+ } catch { /* skip malformed */ }
413
+ } else {
414
+ results.push({ id: trimmed });
415
+ }
416
+ }
417
+ return results;
418
+ } catch {
419
+ return [];
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Validate Nuclei template syntax in a directory.
425
+ * @param {string} templateDir
426
+ * @returns {{ valid: boolean, count: number, errors: string[] }}
427
+ */
428
+ validateTemplates(templateDir) {
429
+ if (!this.available) {
430
+ return { valid: false, count: 0, errors: [TOOL_MISSING('nuclei', this.nucleiPath)] };
431
+ }
432
+ try {
433
+ const bin = this._resolved || this.nucleiPath;
434
+ const result = execFileSync(bin, ['-validate', '-t', templateDir], {
435
+ encoding: 'utf8',
436
+ timeout: 120_000,
437
+ stdio: ['pipe', 'pipe', 'pipe'],
438
+ });
439
+ const lines = result.trim().split('\n').filter(l => l.trim());
440
+ return { valid: true, count: lines.length, errors: [] };
441
+ } catch (err) {
442
+ const stderr = err.stderr?.toString() ?? '';
443
+ const errors = stderr.split('\n').filter(ln => ln.toLowerCase().includes('error'));
444
+ return { valid: false, count: 0, errors };
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Execute a Nuclei command, streaming JSONL from stdout.
450
+ * Replaces Python's select.select with Node.js readline + event streams.
451
+ * @param {string[]} cmd - Full command array
452
+ * @param {string} target
453
+ * @param {number} timeout - Seconds
454
+ * @returns {Promise<ScanResult>}
455
+ */
456
+ _execute(cmd, target, timeout) {
457
+ const cmdStr = cmd.join(' ');
458
+ const start = Date.now();
459
+
460
+ return new Promise((resolve) => {
461
+ const findings = [];
462
+ const parseErrors = [];
463
+ const stderrLines = [];
464
+ let proc;
465
+
466
+ try {
467
+ proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
468
+ } catch (err) {
469
+ return resolve(new ScanResult({
470
+ target,
471
+ command: cmdStr,
472
+ errors: [`Failed to execute nuclei: ${err.message}`],
473
+ }));
474
+ }
475
+
476
+ // Handle spawn error (e.g. ENOENT)
477
+ proc.on('error', (err) => {
478
+ resolve(new ScanResult({
479
+ target,
480
+ command: cmdStr,
481
+ findings,
482
+ errors: [`Failed to execute nuclei: ${err.message}`, ...parseErrors],
483
+ }));
484
+ });
485
+
486
+ // Read stdout line-by-line for JSONL findings (manual line buffer — no readline needed)
487
+ let stdoutBuf = '';
488
+ proc.stdout.on('data', (chunk) => {
489
+ stdoutBuf += chunk.toString();
490
+ let nl;
491
+ while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
492
+ const line = stdoutBuf.slice(0, nl);
493
+ stdoutBuf = stdoutBuf.slice(nl + 1);
494
+ if (line.startsWith('{')) {
495
+ try {
496
+ findings.push(Finding.fromNucleiJson(JSON.parse(line)));
497
+ } catch (err) {
498
+ parseErrors.push(`Parse error: ${err.message}`);
499
+ }
500
+ }
501
+ }
502
+ });
503
+
504
+ // Capture stderr
505
+ proc.stderr.on('data', (chunk) => {
506
+ for (const line of chunk.toString().split('\n')) {
507
+ if (line.trim()) stderrLines.push(line);
508
+ }
509
+ });
510
+
511
+ // Timeout guard
512
+ const timer = setTimeout(() => {
513
+ proc.kill('SIGKILL');
514
+ }, timeout * 1000);
515
+
516
+ proc.on('close', (code, signal) => {
517
+ clearTimeout(timer);
518
+ const elapsed = (Date.now() - start) / 1000;
519
+ const stderrErrors = stderrLines.filter(ln => ln.toLowerCase().includes('error'));
520
+ const timedOut = signal === 'SIGKILL';
521
+
522
+ const sevCounts = {};
523
+ for (const f of findings) {
524
+ sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
525
+ }
526
+
527
+ resolve(new ScanResult({
528
+ target,
529
+ findings,
530
+ stats: { total: findings.length, ...sevCounts },
531
+ errors: [
532
+ ...parseErrors,
533
+ ...stderrErrors,
534
+ ...(timedOut ? [`Scan timed out after ${timeout}s`] : []),
535
+ ],
536
+ command: cmdStr,
537
+ durationSeconds: Math.round(elapsed * 100) / 100,
538
+ success: !timedOut && code === 0,
539
+ }));
540
+ });
541
+ });
542
+ }
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // KatanaRunner
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /** API endpoint path indicators. */
550
+ const API_INDICATORS = ['/api/', '/v1/', '/v2/', '/v3/', '/graphql', '/rest/'];
551
+
552
+ class KatanaRunner {
553
+ /**
554
+ * @param {object} opts
555
+ * @param {string} [opts.katanaPath='katana']
556
+ */
557
+ constructor(opts = {}) {
558
+ this.katanaPath = opts.katanaPath ?? 'katana';
559
+ this._resolved = whichSync(this.katanaPath);
560
+ }
561
+
562
+ get available() {
563
+ return this._resolved !== null;
564
+ }
565
+
566
+ _fail(target) {
567
+ return new CrawlResult({
568
+ target,
569
+ success: false,
570
+ errors: [TOOL_MISSING('katana', this.katanaPath)],
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Crawl a target with Katana, returning discovered URLs and assets.
576
+ * Handles both JSONL and plain-text output modes.
577
+ * @param {string} target
578
+ * @param {object} opts
579
+ * @param {number} [opts.depth=3]
580
+ * @param {boolean} [opts.headless=false]
581
+ * @param {string} [opts.scope='']
582
+ * @param {boolean} [opts.jsCrawl=true]
583
+ * @param {number} [opts.timeout=600]
584
+ * @returns {Promise<CrawlResult>}
585
+ */
586
+ crawl(target, opts = {}) {
587
+ if (!this.available) return Promise.resolve(this._fail(target));
588
+
589
+ const depth = opts.depth ?? 3;
590
+ const headless = opts.headless ?? false;
591
+ const scope = opts.scope ?? '';
592
+ const jsCrawl = opts.jsCrawl !== false;
593
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
594
+
595
+ const bin = this._resolved || this.katanaPath;
596
+ const cmd = [bin, '-u', target, '-d', String(depth), '-jsonl', '-silent'];
597
+ if (headless) cmd.push('-headless');
598
+ if (scope) cmd.push('-cs', scope);
599
+ if (jsCrawl) cmd.push('-jc');
600
+
601
+ const cmdStr = cmd.join(' ');
602
+
603
+ return new Promise((resolve) => {
604
+ const stdoutLines = [];
605
+ const stderrLines = [];
606
+ let proc;
607
+
608
+ try {
609
+ proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
610
+ } catch (err) {
611
+ return resolve(new CrawlResult({
612
+ target,
613
+ command: cmdStr,
614
+ errors: [`Failed to execute katana: ${err.message}`],
615
+ }));
616
+ }
617
+
618
+ proc.on('error', (err) => {
619
+ resolve(new CrawlResult({
620
+ target,
621
+ command: cmdStr,
622
+ errors: [`Failed to execute katana: ${err.message}`],
623
+ }));
624
+ });
625
+
626
+ let stdoutBuf = '';
627
+ proc.stdout.on('data', (chunk) => {
628
+ stdoutBuf += chunk.toString();
629
+ let nl;
630
+ while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
631
+ const line = stdoutBuf.slice(0, nl).trim();
632
+ stdoutBuf = stdoutBuf.slice(nl + 1);
633
+ if (line) stdoutLines.push(line);
634
+ }
635
+ });
636
+
637
+ proc.stderr.on('data', (chunk) => {
638
+ for (const line of chunk.toString().split('\n')) {
639
+ if (line.trim()) stderrLines.push(line.trim());
640
+ }
641
+ });
642
+
643
+ const timer = setTimeout(() => {
644
+ proc.kill('SIGKILL');
645
+ }, timeout * 1000);
646
+
647
+ proc.on('close', (code) => {
648
+ clearTimeout(timer);
649
+ resolve(this._classifyOutput(stdoutLines, target, cmdStr, code === 0));
650
+ });
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Classify raw output lines into urls, endpoints, js files, forms.
656
+ * Handles both JSONL (preferred) and plain-text output.
657
+ */
658
+ _classifyOutput(lines, target, command, processSuccess) {
659
+ const urls = [];
660
+ const endpoints = [];
661
+ const forms = [];
662
+ const jsFiles = [];
663
+
664
+ for (const line of lines) {
665
+ let url = line;
666
+ if (line.startsWith('{')) {
667
+ try {
668
+ const entry = JSON.parse(line);
669
+ url = entry.request?.endpoint ?? line;
670
+ if (entry.request?.method?.toUpperCase() === 'POST') {
671
+ forms.push({
672
+ url,
673
+ method: 'POST',
674
+ body: entry.request?.body ?? '',
675
+ });
676
+ }
677
+ } catch { /* treat as plain text */ }
678
+ }
679
+ urls.push(url);
680
+ const lower = url.toLowerCase();
681
+ if (lower.endsWith('.js') || lower.endsWith('.mjs')) {
682
+ jsFiles.push(url);
683
+ }
684
+ if (API_INDICATORS.some(ind => lower.includes(ind))) {
685
+ endpoints.push(url);
686
+ }
687
+ }
688
+
689
+ return new CrawlResult({
690
+ target,
691
+ urls: [...new Set(urls)].sort(),
692
+ endpoints: [...new Set(endpoints)].sort(),
693
+ forms,
694
+ jsFiles: [...new Set(jsFiles)].sort(),
695
+ command,
696
+ success: processSuccess,
697
+ });
698
+ }
699
+
700
+ /**
701
+ * Crawl and return only API-like endpoints.
702
+ * @param {string} target
703
+ * @param {object} opts
704
+ * @param {number} [opts.timeout=600]
705
+ * @returns {Promise<string[]>}
706
+ */
707
+ async extractEndpoints(target, opts = {}) {
708
+ const result = await this.crawl(target, { depth: 2, jsCrawl: true, timeout: opts.timeout });
709
+ return result.endpoints;
710
+ }
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // File extension → Nuclei tag mapping for PR scans
715
+ // ---------------------------------------------------------------------------
716
+
717
+ const EXT_TAG_MAP = Object.freeze({
718
+ '.yaml': ['misconfig'],
719
+ '.yml': ['misconfig'],
720
+ '.json': ['misconfig', 'exposure'],
721
+ '.toml': ['misconfig'],
722
+ '.env': ['exposure', 'token'],
723
+ '.py': ['sqli', 'xss', 'ssrf', 'rce'],
724
+ '.js': ['sqli', 'xss', 'ssrf', 'rce', 'prototype-pollution'],
725
+ '.ts': ['sqli', 'xss', 'ssrf', 'rce'],
726
+ '.go': ['sqli', 'ssrf', 'rce'],
727
+ '.java': ['sqli', 'xss', 'ssrf', 'rce', 'deserialization'],
728
+ '.php': ['sqli', 'xss', 'ssrf', 'rce', 'lfi'],
729
+ '.rb': ['sqli', 'xss', 'ssrf', 'rce'],
730
+ '.tf': ['misconfig', 'exposure'],
731
+ '.dockerfile': ['misconfig'],
732
+ });
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // ScanPipeline
736
+ // ---------------------------------------------------------------------------
737
+
738
+ class ScanPipeline {
739
+ /**
740
+ * Orchestrates Katana crawl → Nuclei scan → memory storage.
741
+ * @param {object} opts
742
+ * @param {NucleiRunner} opts.nucleiRunner
743
+ * @param {KatanaRunner} opts.katanaRunner
744
+ * @param {function} [opts.memoryCallback] - fn(findings, target, context) => string[]
745
+ */
746
+ constructor(opts = {}) {
747
+ this.nucleiRunner = opts.nucleiRunner;
748
+ this.katanaRunner = opts.katanaRunner;
749
+ this.memoryCallback = opts.memoryCallback ?? null;
750
+ }
751
+
752
+ /**
753
+ * Full pipeline: crawl → scan → store findings.
754
+ * @param {string} target
755
+ * @param {object} opts
756
+ * @param {string} [opts.profile='pentest']
757
+ * @returns {Promise<PipelineResult>}
758
+ */
759
+ async fullScan(target, opts = {}) {
760
+ const profile = opts.profile ?? 'pentest';
761
+ const errors = [];
762
+
763
+ const crawlResult = await this.katanaRunner.crawl(target);
764
+ if (!crawlResult.success) {
765
+ errors.push(...crawlResult.errors);
766
+ }
767
+
768
+ const scanResult = await this.nucleiRunner.scan(target, { profile });
769
+ if (!scanResult.success) {
770
+ errors.push(...scanResult.errors);
771
+ }
772
+
773
+ const stored = this._storeFindings(scanResult.findings, target, 'full_scan');
774
+
775
+ return new PipelineResult({
776
+ target,
777
+ crawl: crawlResult,
778
+ scan: scanResult,
779
+ storedEntryIds: stored,
780
+ errors,
781
+ success: scanResult.success,
782
+ });
783
+ }
784
+
785
+ /**
786
+ * Scan PR changes — derives Nuclei tags from changed file extensions.
787
+ * @param {string} repoUrl
788
+ * @param {number} prNumber
789
+ * @param {string[]} changedFiles
790
+ * @returns {Promise<PipelineResult>}
791
+ */
792
+ async prScan(repoUrl, prNumber, changedFiles) {
793
+ const tags = new Set();
794
+ for (const fpath of changedFiles) {
795
+ const ext = extname(fpath).toLowerCase();
796
+ const mapped = EXT_TAG_MAP[ext];
797
+ if (mapped) mapped.forEach(t => tags.add(t));
798
+ const basename = fpath.split('/').pop().toLowerCase();
799
+ if (basename === 'dockerfile' || basename === 'docker-compose.yml') {
800
+ tags.add('misconfig');
801
+ tags.add('exposure');
802
+ }
803
+ }
804
+ const effTags = tags.size > 0 ? [...tags].sort() : ['cve', 'misconfig'];
805
+
806
+ const scanResult = await this.nucleiRunner.scan(repoUrl, { profile: 'pentest', tags: effTags });
807
+ const stored = this._storeFindings(scanResult.findings, repoUrl, `pr_scan:PR-${prNumber}`);
808
+
809
+ return new PipelineResult({
810
+ target: repoUrl,
811
+ scan: scanResult,
812
+ storedEntryIds: stored,
813
+ errors: scanResult.errors,
814
+ success: scanResult.success,
815
+ });
816
+ }
817
+
818
+ /**
819
+ * Re-run scans for prior engagement findings to verify remediation.
820
+ * @param {string} engagementId
821
+ * @returns {Promise<PipelineResult>}
822
+ */
823
+ async regressionScan(engagementId) {
824
+ // Regression scan requires memory to load prior findings
825
+ return new PipelineResult({
826
+ target: engagementId,
827
+ success: false,
828
+ errors: ['Regression scan requires memory integration — not yet implemented in Node.js'],
829
+ });
830
+ }
831
+
832
+ /**
833
+ * Persist findings via the memory callback if provided.
834
+ * @param {Finding[]} findings
835
+ * @param {string} target
836
+ * @param {string} context
837
+ * @returns {string[]} Stored entry IDs
838
+ */
839
+ _storeFindings(findings, target, context) {
840
+ if (!this.memoryCallback || !findings.length) return [];
841
+ try {
842
+ return this.memoryCallback(findings, target, context);
843
+ } catch {
844
+ return [];
845
+ }
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Extract file extension from a path.
851
+ * @param {string} filePath
852
+ * @returns {string}
853
+ */
854
+ function extname(filePath) {
855
+ const idx = filePath.lastIndexOf('.');
856
+ return idx >= 0 ? filePath.slice(idx) : '';
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Exports
861
+ // ---------------------------------------------------------------------------
862
+
863
+ export {
864
+ // Enums
865
+ ScanDomain,
866
+ PROFILE_CONFIGS,
867
+ EXT_TAG_MAP,
868
+ // Data classes
869
+ Finding,
870
+ ScanResult,
871
+ CrawlResult,
872
+ PipelineResult,
873
+ ScanProfile,
874
+ // Runners
875
+ NucleiRunner,
876
+ KatanaRunner,
877
+ ScanPipeline,
878
+ // Utilities
879
+ whichSync,
880
+ };