@vulcn/driver-browser 0.2.0 → 0.4.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/dist/index.js CHANGED
@@ -368,9 +368,12 @@ var BrowserRecorder = class _BrowserRecorder {
368
368
  };
369
369
 
370
370
  // src/runner.ts
371
- var BrowserRunner = class _BrowserRunner {
371
+ var BrowserRunner = class {
372
372
  /**
373
- * Execute a session with security payloads
373
+ * Execute a session with security payloads.
374
+ *
375
+ * If ctx.options.browser is provided, reuses that browser (persistent mode).
376
+ * Otherwise, launches and closes its own browser (standalone mode).
374
377
  */
375
378
  static async execute(session, ctx) {
376
379
  const config = session.driverConfig;
@@ -396,83 +399,39 @@ var BrowserRunner = class _BrowserRunner {
396
399
  ]
397
400
  };
398
401
  }
399
- const { browser } = await launchBrowser({
400
- browser: browserType,
401
- headless
402
- });
403
- const context = await browser.newContext({ viewport });
402
+ const sharedBrowser = ctx.options.browser;
403
+ const ownBrowser = sharedBrowser ? null : (await launchBrowser({ browser: browserType, headless })).browser;
404
+ const browser = sharedBrowser ?? ownBrowser;
405
+ const storageState = ctx.options.storageState;
406
+ const contextOptions = { viewport };
407
+ if (storageState) {
408
+ contextOptions.storageState = JSON.parse(storageState);
409
+ }
410
+ const context = await browser.newContext(contextOptions);
404
411
  const page = await context.newPage();
405
412
  await ctx.options.onPageReady?.(page);
406
413
  const eventFindings = [];
407
414
  let currentPayloadInfo = null;
408
- const dialogHandler = async (dialog) => {
409
- if (currentPayloadInfo) {
410
- const message = dialog.message();
411
- const dialogType = dialog.type();
412
- if (dialogType !== "beforeunload") {
413
- eventFindings.push({
414
- type: "xss",
415
- severity: "high",
416
- title: `XSS Confirmed - ${dialogType}() triggered`,
417
- description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
418
- stepId: currentPayloadInfo.stepId,
419
- payload: currentPayloadInfo.payloadValue,
420
- url: page.url(),
421
- evidence: `Dialog type: ${dialogType}, Message: ${message}`,
422
- metadata: {
423
- dialogType,
424
- dialogMessage: message,
425
- detectionMethod: "dialog"
426
- }
427
- });
428
- }
429
- }
430
- try {
431
- await dialog.dismiss();
432
- } catch {
433
- }
434
- };
435
- const consoleHandler = async (msg) => {
436
- if (currentPayloadInfo && msg.type() === "log") {
437
- const text = msg.text();
438
- if (text.includes("vulcn") || text.includes(currentPayloadInfo.payloadValue)) {
439
- eventFindings.push({
440
- type: "xss",
441
- severity: "high",
442
- title: "XSS Confirmed - Console Output",
443
- description: `JavaScript console.log was triggered by payload injection`,
444
- stepId: currentPayloadInfo.stepId,
445
- payload: currentPayloadInfo.payloadValue,
446
- url: page.url(),
447
- evidence: `Console output: ${text}`,
448
- metadata: {
449
- consoleType: msg.type(),
450
- detectionMethod: "console"
451
- }
452
- });
453
- }
454
- }
455
- };
415
+ const dialogHandler = createDialogHandler(
416
+ page,
417
+ eventFindings,
418
+ () => currentPayloadInfo
419
+ );
420
+ const consoleHandler = createConsoleHandler(
421
+ eventFindings,
422
+ () => currentPayloadInfo
423
+ );
456
424
  page.on("dialog", dialogHandler);
457
425
  page.on("console", consoleHandler);
458
426
  try {
459
427
  const injectableSteps = session.steps.filter(
460
428
  (step) => step.type === "browser.input" && step.injectable !== false
461
429
  );
462
- const allPayloads = [];
463
- const payloadsByCategory = payloads.map(
464
- (ps) => ps.payloads.map((value) => ({ payloadSet: ps, value }))
465
- );
466
- const maxLen = Math.max(...payloadsByCategory.map((c) => c.length));
467
- for (let i = 0; i < maxLen; i++) {
468
- for (const category of payloadsByCategory) {
469
- if (i < category.length) {
470
- allPayloads.push(category[i]);
471
- }
472
- }
473
- }
430
+ const allPayloads = interleavePayloads(payloads);
474
431
  const confirmedTypes = /* @__PURE__ */ new Set();
475
432
  for (const injectableStep of injectableSteps) {
433
+ let isFirstPayload = true;
434
+ let formPageUrl = null;
476
435
  for (const { payloadSet, value } of allPayloads) {
477
436
  const stepTypeKey = `${injectableStep.id}::${payloadSet.category}`;
478
437
  if (confirmedTypes.has(stepTypeKey)) {
@@ -484,14 +443,35 @@ var BrowserRunner = class _BrowserRunner {
484
443
  payloadSet,
485
444
  payloadValue: value
486
445
  };
487
- await _BrowserRunner.replayWithPayload(
488
- page,
489
- session,
490
- injectableStep,
491
- value,
492
- startUrl
493
- );
494
- const reflectionFinding = await _BrowserRunner.checkReflection(
446
+ if (isFirstPayload) {
447
+ await replayWithPayload(
448
+ page,
449
+ session,
450
+ injectableStep,
451
+ value,
452
+ startUrl
453
+ );
454
+ isFirstPayload = false;
455
+ formPageUrl = startUrl;
456
+ } else {
457
+ const cycled = await cyclePayload(
458
+ page,
459
+ session,
460
+ injectableStep,
461
+ value,
462
+ formPageUrl ?? startUrl
463
+ );
464
+ if (!cycled) {
465
+ await replayWithPayload(
466
+ page,
467
+ session,
468
+ injectableStep,
469
+ value,
470
+ startUrl
471
+ );
472
+ }
473
+ }
474
+ const reflectionFinding = await checkReflection(
495
475
  page,
496
476
  injectableStep,
497
477
  payloadSet,
@@ -517,6 +497,7 @@ var BrowserRunner = class _BrowserRunner {
517
497
  ctx.options.onStepComplete?.(injectableStep.id, payloadsTested);
518
498
  } catch (err) {
519
499
  errors.push(`${injectableStep.id}: ${String(err)}`);
500
+ isFirstPayload = true;
520
501
  }
521
502
  }
522
503
  }
@@ -525,7 +506,10 @@ var BrowserRunner = class _BrowserRunner {
525
506
  page.off("console", consoleHandler);
526
507
  currentPayloadInfo = null;
527
508
  await ctx.options.onBeforeClose?.(page);
528
- await browser.close();
509
+ await context.close();
510
+ if (ownBrowser) {
511
+ await ownBrowser.close();
512
+ }
529
513
  }
530
514
  return {
531
515
  findings: ctx.findings,
@@ -535,136 +519,506 @@ var BrowserRunner = class _BrowserRunner {
535
519
  errors
536
520
  };
537
521
  }
538
- /**
539
- * Replay session steps with payload injected at target step
540
- *
541
- * IMPORTANT: We replay ALL steps, not just up to the injectable step.
542
- * The injection replaces the input value, but subsequent steps (like
543
- * clicking submit) must still execute so the payload reaches the server
544
- * and gets reflected back in the response.
545
- */
546
- static async replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
547
- await page.goto(startUrl, { waitUntil: "domcontentloaded" });
548
- let injected = false;
549
- for (const step of session.steps) {
550
- const browserStep = step;
551
- try {
552
- switch (browserStep.type) {
553
- case "browser.navigate":
554
- if (injected && browserStep.url.includes("sid=")) {
555
- continue;
556
- }
557
- await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
558
- break;
559
- case "browser.click":
560
- if (injected) {
561
- await Promise.all([
562
- page.waitForNavigation({
563
- waitUntil: "domcontentloaded",
564
- timeout: 5e3
565
- }).catch(() => {
566
- }),
567
- page.click(browserStep.selector, { timeout: 5e3 })
568
- ]);
569
- } else {
570
- await page.click(browserStep.selector, { timeout: 5e3 });
571
- }
572
- break;
573
- case "browser.input": {
574
- const value = step.id === targetStep.id ? payloadValue : browserStep.value;
575
- await page.fill(browserStep.selector, value, { timeout: 5e3 });
576
- if (step.id === targetStep.id) {
577
- injected = true;
578
- }
579
- break;
522
+ };
523
+ function createDialogHandler(page, eventFindings, getPayloadInfo) {
524
+ return async (dialog) => {
525
+ const info = getPayloadInfo();
526
+ if (info) {
527
+ const message = dialog.message();
528
+ const dialogType = dialog.type();
529
+ if (dialogType !== "beforeunload") {
530
+ eventFindings.push({
531
+ type: "xss",
532
+ severity: "high",
533
+ title: `XSS Confirmed - ${dialogType}() triggered`,
534
+ description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
535
+ stepId: info.stepId,
536
+ payload: info.payloadValue,
537
+ url: page.url(),
538
+ evidence: `Dialog type: ${dialogType}, Message: ${message}`,
539
+ metadata: {
540
+ dialogType,
541
+ dialogMessage: message,
542
+ detectionMethod: "dialog"
580
543
  }
581
- case "browser.keypress": {
582
- const modifiers = browserStep.modifiers ?? [];
583
- for (const mod of modifiers) {
584
- await page.keyboard.down(
585
- mod
586
- );
587
- }
588
- await page.keyboard.press(browserStep.key);
589
- for (const mod of modifiers.reverse()) {
590
- await page.keyboard.up(
591
- mod
592
- );
593
- }
594
- break;
544
+ });
545
+ }
546
+ }
547
+ try {
548
+ await dialog.dismiss();
549
+ } catch {
550
+ }
551
+ };
552
+ }
553
+ function createConsoleHandler(eventFindings, getPayloadInfo) {
554
+ return async (msg) => {
555
+ const info = getPayloadInfo();
556
+ if (info && msg.type() === "log") {
557
+ const text = msg.text();
558
+ if (text.includes("vulcn") || text.includes(info.payloadValue)) {
559
+ eventFindings.push({
560
+ type: "xss",
561
+ severity: "high",
562
+ title: "XSS Confirmed - Console Output",
563
+ description: `JavaScript console.log was triggered by payload injection`,
564
+ stepId: info.stepId,
565
+ payload: info.payloadValue,
566
+ url: "",
567
+ evidence: `Console output: ${text}`,
568
+ metadata: {
569
+ consoleType: msg.type(),
570
+ detectionMethod: "console"
595
571
  }
596
- case "browser.scroll":
597
- if (browserStep.selector) {
598
- await page.locator(browserStep.selector).evaluate((el, pos) => {
599
- el.scrollTo(pos.x, pos.y);
600
- }, browserStep.position);
601
- } else {
602
- await page.evaluate((pos) => {
603
- window.scrollTo(pos.x, pos.y);
604
- }, browserStep.position);
605
- }
606
- break;
607
- case "browser.wait":
608
- await page.waitForTimeout(browserStep.duration);
609
- break;
610
- }
611
- } catch {
572
+ });
573
+ }
574
+ }
575
+ };
576
+ }
577
+ async function cyclePayload(page, session, targetStep, payloadValue, formPageUrl) {
578
+ try {
579
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: 5e3 });
580
+ const targetSelector = targetStep.selector;
581
+ const formPresent = await page.waitForSelector(targetSelector, { timeout: 3e3 }).then(() => true).catch(() => false);
582
+ if (!formPresent) {
583
+ await page.goto(formPageUrl, {
584
+ waitUntil: "domcontentloaded",
585
+ timeout: 5e3
586
+ });
587
+ const formPresentAfterNav = await page.waitForSelector(targetSelector, { timeout: 3e3 }).then(() => true).catch(() => false);
588
+ if (!formPresentAfterNav) {
589
+ return false;
612
590
  }
613
591
  }
592
+ await page.fill(targetSelector, payloadValue, { timeout: 5e3 });
593
+ await replayStepsAfter(page, session, targetStep);
614
594
  await page.waitForTimeout(500);
595
+ return true;
596
+ } catch {
597
+ return false;
615
598
  }
616
- /**
617
- * Check for payload reflection in page content
618
- */
619
- static async checkReflection(page, step, payloadSet, payloadValue) {
620
- const content = await page.content();
621
- for (const pattern of payloadSet.detectPatterns) {
622
- if (pattern.test(content)) {
623
- return {
624
- type: payloadSet.category,
625
- severity: _BrowserRunner.getSeverity(payloadSet.category),
626
- title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
627
- description: `Payload pattern was reflected in page content`,
628
- stepId: step.id,
629
- payload: payloadValue,
630
- url: page.url(),
631
- evidence: content.match(pattern)?.[0]?.slice(0, 200)
632
- };
599
+ }
600
+ async function replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
601
+ await page.goto(startUrl, { waitUntil: "domcontentloaded" });
602
+ let injected = false;
603
+ for (const step of session.steps) {
604
+ const browserStep = step;
605
+ try {
606
+ switch (browserStep.type) {
607
+ case "browser.navigate":
608
+ if (injected && browserStep.url.includes("sid=")) {
609
+ continue;
610
+ }
611
+ await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
612
+ break;
613
+ case "browser.click":
614
+ if (injected) {
615
+ await Promise.all([
616
+ page.waitForNavigation({
617
+ waitUntil: "domcontentloaded",
618
+ timeout: 5e3
619
+ }).catch(() => {
620
+ }),
621
+ page.click(browserStep.selector, { timeout: 5e3 })
622
+ ]);
623
+ } else {
624
+ await page.click(browserStep.selector, { timeout: 5e3 });
625
+ }
626
+ break;
627
+ case "browser.input": {
628
+ const value = step.id === targetStep.id ? payloadValue : browserStep.value;
629
+ await page.fill(browserStep.selector, value, { timeout: 5e3 });
630
+ if (step.id === targetStep.id) {
631
+ injected = true;
632
+ }
633
+ break;
634
+ }
635
+ case "browser.keypress": {
636
+ const modifiers = browserStep.modifiers ?? [];
637
+ for (const mod of modifiers) {
638
+ await page.keyboard.down(
639
+ mod
640
+ );
641
+ }
642
+ await page.keyboard.press(browserStep.key);
643
+ for (const mod of modifiers.reverse()) {
644
+ await page.keyboard.up(mod);
645
+ }
646
+ break;
647
+ }
648
+ case "browser.scroll":
649
+ if (browserStep.selector) {
650
+ await page.locator(browserStep.selector).evaluate((el, pos) => {
651
+ el.scrollTo(pos.x, pos.y);
652
+ }, browserStep.position);
653
+ } else {
654
+ await page.evaluate((pos) => {
655
+ window.scrollTo(pos.x, pos.y);
656
+ }, browserStep.position);
657
+ }
658
+ break;
659
+ case "browser.wait":
660
+ await page.waitForTimeout(browserStep.duration);
661
+ break;
633
662
  }
663
+ } catch {
664
+ }
665
+ }
666
+ await page.waitForTimeout(500);
667
+ }
668
+ async function replayStepsAfter(page, session, targetStep) {
669
+ let pastTarget = false;
670
+ for (const step of session.steps) {
671
+ if (step.id === targetStep.id) {
672
+ pastTarget = true;
673
+ continue;
674
+ }
675
+ if (!pastTarget) continue;
676
+ const browserStep = step;
677
+ try {
678
+ switch (browserStep.type) {
679
+ case "browser.navigate":
680
+ break;
681
+ case "browser.click":
682
+ await Promise.all([
683
+ page.waitForNavigation({
684
+ waitUntil: "domcontentloaded",
685
+ timeout: 5e3
686
+ }).catch(() => {
687
+ }),
688
+ page.click(browserStep.selector, { timeout: 5e3 })
689
+ ]);
690
+ break;
691
+ case "browser.input":
692
+ await page.fill(browserStep.selector, browserStep.value, {
693
+ timeout: 5e3
694
+ });
695
+ break;
696
+ case "browser.keypress": {
697
+ const modifiers = browserStep.modifiers ?? [];
698
+ for (const mod of modifiers) {
699
+ await page.keyboard.down(
700
+ mod
701
+ );
702
+ }
703
+ await page.keyboard.press(browserStep.key);
704
+ for (const mod of modifiers.reverse()) {
705
+ await page.keyboard.up(mod);
706
+ }
707
+ break;
708
+ }
709
+ case "browser.scroll":
710
+ break;
711
+ // skip scrolls in fast path
712
+ case "browser.wait":
713
+ break;
714
+ }
715
+ } catch {
634
716
  }
635
- if (content.includes(payloadValue)) {
717
+ }
718
+ await page.waitForTimeout(500);
719
+ }
720
+ async function checkReflection(page, step, payloadSet, payloadValue) {
721
+ const content = await page.content();
722
+ for (const pattern of payloadSet.detectPatterns) {
723
+ if (pattern.test(content)) {
636
724
  return {
637
725
  type: payloadSet.category,
638
- severity: "medium",
639
- title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
640
- description: `Payload was reflected in page without encoding`,
726
+ severity: getSeverity(payloadSet.category),
727
+ title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
728
+ description: `Payload pattern was reflected in page content`,
641
729
  stepId: step.id,
642
730
  payload: payloadValue,
643
- url: page.url()
731
+ url: page.url(),
732
+ evidence: content.match(pattern)?.[0]?.slice(0, 200)
644
733
  };
645
734
  }
646
- return void 0;
647
735
  }
648
- /**
649
- * Determine severity based on vulnerability category
650
- */
651
- static getSeverity(category) {
652
- switch (category) {
653
- case "sqli":
654
- case "command-injection":
655
- case "xxe":
656
- return "critical";
657
- case "xss":
658
- case "ssrf":
659
- case "path-traversal":
660
- return "high";
661
- case "open-redirect":
662
- return "medium";
663
- default:
664
- return "medium";
736
+ if (content.includes(payloadValue)) {
737
+ return {
738
+ type: payloadSet.category,
739
+ severity: "medium",
740
+ title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
741
+ description: `Payload was reflected in page without encoding`,
742
+ stepId: step.id,
743
+ payload: payloadValue,
744
+ url: page.url()
745
+ };
746
+ }
747
+ return void 0;
748
+ }
749
+ function getSeverity(category) {
750
+ switch (category) {
751
+ case "sqli":
752
+ case "command-injection":
753
+ case "xxe":
754
+ return "critical";
755
+ case "xss":
756
+ case "ssrf":
757
+ case "path-traversal":
758
+ return "high";
759
+ case "open-redirect":
760
+ return "medium";
761
+ default:
762
+ return "medium";
763
+ }
764
+ }
765
+ function interleavePayloads(payloads) {
766
+ const result = [];
767
+ const payloadsByCategory = payloads.map(
768
+ (ps) => ps.payloads.map((value) => ({ payloadSet: ps, value }))
769
+ );
770
+ const maxLen = Math.max(...payloadsByCategory.map((c) => c.length));
771
+ for (let i = 0; i < maxLen; i++) {
772
+ for (const category of payloadsByCategory) {
773
+ if (i < category.length) {
774
+ result.push(category[i]);
775
+ }
665
776
  }
666
777
  }
667
- };
778
+ return result;
779
+ }
780
+
781
+ // src/http-scanner.ts
782
+ async function httpScan(requests, payloads, options = {}) {
783
+ const timeout = options.timeout ?? 1e4;
784
+ const concurrency = options.concurrency ?? 10;
785
+ const start = Date.now();
786
+ const findings = [];
787
+ const reflectedRequests = [];
788
+ let requestsSent = 0;
789
+ const tasks = [];
790
+ for (const request of requests) {
791
+ if (!request.injectableField) continue;
792
+ for (const payloadSet of payloads) {
793
+ for (const value of payloadSet.payloads) {
794
+ tasks.push({ request, payloadSet, value });
795
+ }
796
+ }
797
+ }
798
+ const totalTasks = tasks.length;
799
+ if (totalTasks === 0) {
800
+ return { requestsSent: 0, duration: 0, findings, reflectedRequests };
801
+ }
802
+ for (let i = 0; i < tasks.length; i += concurrency) {
803
+ const batch = tasks.slice(i, i + concurrency);
804
+ const results = await Promise.allSettled(
805
+ batch.map(async ({ request, payloadSet, value }) => {
806
+ try {
807
+ const body = await sendPayload(request, value, {
808
+ timeout,
809
+ cookies: options.cookies,
810
+ headers: options.headers
811
+ });
812
+ requestsSent++;
813
+ const finding = checkHttpReflection(body, request, payloadSet, value);
814
+ if (finding) {
815
+ findings.push(finding);
816
+ reflectedRequests.push({
817
+ request,
818
+ payload: value,
819
+ category: payloadSet.category
820
+ });
821
+ }
822
+ } catch {
823
+ requestsSent++;
824
+ }
825
+ })
826
+ );
827
+ const completed = Math.min(i + batch.length, totalTasks);
828
+ options.onProgress?.(completed, totalTasks);
829
+ for (const result of results) {
830
+ if (result.status === "rejected") {
831
+ }
832
+ }
833
+ }
834
+ return {
835
+ requestsSent,
836
+ duration: Date.now() - start,
837
+ findings,
838
+ reflectedRequests
839
+ };
840
+ }
841
+ async function sendPayload(request, payload, options) {
842
+ const { method, url, headers, body, contentType, injectableField } = request;
843
+ const reqHeaders = {
844
+ ...headers,
845
+ ...options.headers ?? {}
846
+ };
847
+ if (options.cookies) {
848
+ reqHeaders["Cookie"] = options.cookies;
849
+ }
850
+ delete reqHeaders["content-length"];
851
+ delete reqHeaders["Content-Length"];
852
+ let requestUrl = url;
853
+ let requestBody;
854
+ if (method.toUpperCase() === "GET") {
855
+ requestUrl = injectIntoUrl(url, injectableField, payload);
856
+ } else {
857
+ requestBody = injectIntoBody(body, contentType, injectableField, payload);
858
+ if (contentType) {
859
+ reqHeaders["Content-Type"] = contentType;
860
+ } else {
861
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded";
862
+ }
863
+ }
864
+ const controller = new AbortController();
865
+ const timer = setTimeout(() => controller.abort(), options.timeout);
866
+ try {
867
+ const response = await fetch(requestUrl, {
868
+ method: method.toUpperCase(),
869
+ headers: reqHeaders,
870
+ body: requestBody,
871
+ signal: controller.signal,
872
+ redirect: "follow"
873
+ });
874
+ return await response.text();
875
+ } finally {
876
+ clearTimeout(timer);
877
+ }
878
+ }
879
+ function injectIntoUrl(url, field, payload) {
880
+ try {
881
+ const parsed = new URL(url);
882
+ parsed.searchParams.set(field, payload);
883
+ return parsed.toString();
884
+ } catch {
885
+ const separator = url.includes("?") ? "&" : "?";
886
+ return `${url}${separator}${encodeURIComponent(field)}=${encodeURIComponent(payload)}`;
887
+ }
888
+ }
889
+ function injectIntoBody(body, contentType, field, payload) {
890
+ if (!body) {
891
+ return `${encodeURIComponent(field)}=${encodeURIComponent(payload)}`;
892
+ }
893
+ const ct = (contentType ?? "").toLowerCase();
894
+ if (ct.includes("application/json")) {
895
+ return injectIntoJson(body, field, payload);
896
+ }
897
+ if (ct.includes("multipart/form-data")) {
898
+ return injectIntoMultipart(body, field, payload);
899
+ }
900
+ return injectIntoFormUrlEncoded(body, field, payload);
901
+ }
902
+ function injectIntoFormUrlEncoded(body, field, payload) {
903
+ const params = new URLSearchParams(body);
904
+ params.set(field, payload);
905
+ return params.toString();
906
+ }
907
+ function injectIntoJson(body, field, payload) {
908
+ try {
909
+ const parsed = JSON.parse(body);
910
+ if (typeof parsed === "object" && parsed !== null) {
911
+ parsed[field] = payload;
912
+ return JSON.stringify(parsed);
913
+ }
914
+ } catch {
915
+ }
916
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
917
+ const regex = new RegExp(`("${escaped}"\\s*:\\s*)"[^"]*"`, "g");
918
+ const replaced = body.replace(regex, `$1"${payload}"`);
919
+ if (replaced !== body) return replaced;
920
+ return body;
921
+ }
922
+ function injectIntoMultipart(body, field, payload) {
923
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
924
+ const regex = new RegExp(
925
+ `(Content-Disposition:\\s*form-data;\\s*name="${escaped}"\\r?\\n\\r?\\n)[^\\r\\n-]*`,
926
+ "i"
927
+ );
928
+ return body.replace(regex, `$1${payload}`);
929
+ }
930
+ function checkHttpReflection(responseBody, request, payloadSet, payloadValue) {
931
+ for (const pattern of payloadSet.detectPatterns) {
932
+ if (pattern.test(responseBody)) {
933
+ return {
934
+ type: payloadSet.category,
935
+ severity: getSeverity2(payloadSet.category),
936
+ title: `${payloadSet.category.toUpperCase()} reflection detected (HTTP)`,
937
+ description: `Payload pattern was reflected in HTTP response body. Needs browser confirmation for execution proof.`,
938
+ stepId: `http-${request.sessionName}`,
939
+ payload: payloadValue,
940
+ url: request.url,
941
+ evidence: responseBody.match(pattern)?.[0]?.slice(0, 200),
942
+ metadata: {
943
+ detectionMethod: "tier1-http",
944
+ needsBrowserConfirmation: true,
945
+ requestMethod: request.method,
946
+ injectableField: request.injectableField
947
+ }
948
+ };
949
+ }
950
+ }
951
+ if (responseBody.includes(payloadValue)) {
952
+ return {
953
+ type: payloadSet.category,
954
+ severity: "medium",
955
+ title: `Potential ${payloadSet.category.toUpperCase()} \u2014 payload reflected in HTTP response`,
956
+ description: `Payload was reflected in HTTP response without encoding. Escalate to browser for execution proof.`,
957
+ stepId: `http-${request.sessionName}`,
958
+ payload: payloadValue,
959
+ url: request.url,
960
+ metadata: {
961
+ detectionMethod: "tier1-http",
962
+ needsBrowserConfirmation: true,
963
+ requestMethod: request.method,
964
+ injectableField: request.injectableField
965
+ }
966
+ };
967
+ }
968
+ return void 0;
969
+ }
970
+ function getSeverity2(category) {
971
+ switch (category) {
972
+ case "sqli":
973
+ case "command-injection":
974
+ case "xxe":
975
+ return "critical";
976
+ case "xss":
977
+ case "ssrf":
978
+ case "path-traversal":
979
+ return "high";
980
+ case "open-redirect":
981
+ return "medium";
982
+ default:
983
+ return "medium";
984
+ }
985
+ }
986
+ function buildCapturedRequests(forms) {
987
+ const requests = [];
988
+ for (const form of forms) {
989
+ const injectableInputs = form.inputs.filter((i) => i.injectable);
990
+ if (injectableInputs.length === 0) continue;
991
+ let actionUrl;
992
+ try {
993
+ actionUrl = new URL(form.action, form.pageUrl).toString();
994
+ } catch {
995
+ actionUrl = form.pageUrl;
996
+ }
997
+ const method = (form.method || "GET").toUpperCase();
998
+ for (const input of injectableInputs) {
999
+ const formParams = new URLSearchParams();
1000
+ for (const inp of form.inputs) {
1001
+ formParams.set(inp.name || inp.type, inp.injectable ? "test" : "");
1002
+ }
1003
+ const request = {
1004
+ method,
1005
+ url: method === "GET" ? actionUrl : actionUrl,
1006
+ headers: {
1007
+ "User-Agent": "Vulcn/1.0 (Security Scanner)",
1008
+ Accept: "text/html,application/xhtml+xml,*/*"
1009
+ },
1010
+ ...method !== "GET" ? {
1011
+ body: formParams.toString(),
1012
+ contentType: "application/x-www-form-urlencoded"
1013
+ } : {},
1014
+ injectableField: input.name || input.type,
1015
+ sessionName: form.sessionName
1016
+ };
1017
+ requests.push(request);
1018
+ }
1019
+ }
1020
+ return requests;
1021
+ }
668
1022
 
669
1023
  // src/crawler.ts
670
1024
  var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
@@ -701,7 +1055,8 @@ async function crawlAndBuildSessions(config, options = {}) {
701
1055
  headless: config.headless ?? true
702
1056
  });
703
1057
  const context = await browser.newContext({
704
- viewport: config.viewport ?? { width: 1280, height: 720 }
1058
+ viewport: config.viewport ?? { width: 1280, height: 720 },
1059
+ ...options.storageState ? { storageState: JSON.parse(options.storageState) } : {}
705
1060
  });
706
1061
  try {
707
1062
  while (queue.length > 0 && visited.size < opts.maxPages) {
@@ -751,7 +1106,20 @@ async function crawlAndBuildSessions(config, options = {}) {
751
1106
  console.log(
752
1107
  `[crawler] Complete: ${visited.size} page(s), ${allForms.length} form(s)`
753
1108
  );
754
- return buildSessions(allForms);
1109
+ const sessions = buildSessions(allForms);
1110
+ const capturedRequests = buildCapturedRequests(
1111
+ allForms.filter((f) => f.inputs.some((i) => i.injectable)).map((form, idx) => ({
1112
+ pageUrl: form.pageUrl,
1113
+ action: form.action,
1114
+ method: form.method,
1115
+ inputs: form.inputs,
1116
+ sessionName: sessions[idx]?.name ?? `form-${idx + 1}`
1117
+ }))
1118
+ );
1119
+ console.log(
1120
+ `[crawler] Generated ${sessions.length} session(s), ${capturedRequests.length} HTTP request(s) for Tier 1`
1121
+ );
1122
+ return { sessions, capturedRequests };
755
1123
  }
756
1124
  async function discoverForms(page, pageUrl) {
757
1125
  const forms = [];
@@ -1016,6 +1384,182 @@ function normalizeUrl(url) {
1016
1384
  }
1017
1385
  }
1018
1386
 
1387
+ // src/auth.ts
1388
+ async function detectLoginForm(page) {
1389
+ return page.evaluate(() => {
1390
+ function findUsernameInput(container) {
1391
+ const selectors = [
1392
+ 'input[autocomplete="username"]',
1393
+ 'input[autocomplete="email"]',
1394
+ 'input[type="email"]',
1395
+ 'input[name*="user" i]',
1396
+ 'input[name*="login" i]',
1397
+ 'input[name*="email" i]',
1398
+ 'input[id*="user" i]',
1399
+ 'input[id*="login" i]',
1400
+ 'input[id*="email" i]',
1401
+ 'input[name*="name" i]',
1402
+ 'input[type="text"]'
1403
+ ];
1404
+ for (const sel of selectors) {
1405
+ const el = container.querySelector(sel);
1406
+ if (el && el.type !== "password" && el.type !== "hidden") {
1407
+ return el;
1408
+ }
1409
+ }
1410
+ return null;
1411
+ }
1412
+ function findSubmitButton(container) {
1413
+ const selectors = [
1414
+ 'button[type="submit"]',
1415
+ 'input[type="submit"]',
1416
+ "button:not([type])",
1417
+ 'button[type="button"]'
1418
+ ];
1419
+ for (const sel of selectors) {
1420
+ const el = container.querySelector(sel);
1421
+ if (el) return el;
1422
+ }
1423
+ return null;
1424
+ }
1425
+ function getSelector(el) {
1426
+ if (el.id) return `#${CSS.escape(el.id)}`;
1427
+ if (el.getAttribute("name"))
1428
+ return `${el.tagName.toLowerCase()}[name="${CSS.escape(el.getAttribute("name"))}"]`;
1429
+ if (el.getAttribute("type") && el.tagName === "INPUT")
1430
+ return `input[type="${el.getAttribute("type")}"]`;
1431
+ const parent = el.parentElement;
1432
+ if (!parent) return el.tagName.toLowerCase();
1433
+ const siblings = Array.from(parent.children);
1434
+ const index = siblings.indexOf(el) + 1;
1435
+ return `${parent.tagName.toLowerCase()} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
1436
+ }
1437
+ const forms = document.querySelectorAll("form");
1438
+ for (const form of forms) {
1439
+ const passwordInput2 = form.querySelector(
1440
+ 'input[type="password"]'
1441
+ );
1442
+ if (!passwordInput2) continue;
1443
+ const usernameInput = findUsernameInput(form);
1444
+ const submitButton = findSubmitButton(form);
1445
+ return {
1446
+ usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
1447
+ passwordSelector: getSelector(passwordInput2),
1448
+ submitSelector: submitButton ? getSelector(submitButton) : null,
1449
+ autoDetected: true
1450
+ };
1451
+ }
1452
+ const passwordInput = document.querySelector(
1453
+ 'input[type="password"]'
1454
+ );
1455
+ if (passwordInput) {
1456
+ const usernameInput = findUsernameInput(document.body);
1457
+ const submitButton = findSubmitButton(document.body);
1458
+ return {
1459
+ usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
1460
+ passwordSelector: getSelector(passwordInput),
1461
+ submitSelector: submitButton ? getSelector(submitButton) : null,
1462
+ autoDetected: true
1463
+ };
1464
+ }
1465
+ return null;
1466
+ });
1467
+ }
1468
+ async function performLogin(page, context, credentials, options) {
1469
+ const loginUrl = credentials.loginUrl ?? options.targetUrl;
1470
+ await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 15e3 });
1471
+ let usernameSelector;
1472
+ let passwordSelector;
1473
+ let submitSelector;
1474
+ if (credentials.userSelector && credentials.passSelector) {
1475
+ usernameSelector = credentials.userSelector;
1476
+ passwordSelector = credentials.passSelector;
1477
+ submitSelector = null;
1478
+ } else {
1479
+ const form = await detectLoginForm(page);
1480
+ if (!form) {
1481
+ return {
1482
+ success: false,
1483
+ message: `No login form detected on ${loginUrl}. Use --user-field and --pass-field to specify selectors.`
1484
+ };
1485
+ }
1486
+ usernameSelector = form.usernameSelector;
1487
+ passwordSelector = form.passwordSelector;
1488
+ submitSelector = form.submitSelector;
1489
+ }
1490
+ try {
1491
+ await page.fill(usernameSelector, credentials.username, { timeout: 5e3 });
1492
+ } catch {
1493
+ return {
1494
+ success: false,
1495
+ message: `Could not find username field: ${usernameSelector}`
1496
+ };
1497
+ }
1498
+ try {
1499
+ await page.fill(passwordSelector, credentials.password, { timeout: 5e3 });
1500
+ } catch {
1501
+ return {
1502
+ success: false,
1503
+ message: `Could not find password field: ${passwordSelector}`
1504
+ };
1505
+ }
1506
+ try {
1507
+ if (submitSelector) {
1508
+ await Promise.all([
1509
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
1510
+ }),
1511
+ page.click(submitSelector, { timeout: 5e3 })
1512
+ ]);
1513
+ } else {
1514
+ await Promise.all([
1515
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
1516
+ }),
1517
+ page.press(passwordSelector, "Enter")
1518
+ ]);
1519
+ }
1520
+ } catch {
1521
+ return {
1522
+ success: false,
1523
+ message: "Failed to submit login form"
1524
+ };
1525
+ }
1526
+ await page.waitForTimeout(1e3);
1527
+ const bodyText = await page.textContent("body").catch(() => "");
1528
+ if (options.loggedOutIndicator && bodyText?.includes(options.loggedOutIndicator)) {
1529
+ return {
1530
+ success: false,
1531
+ message: `Login failed \u2014 "${options.loggedOutIndicator}" still visible on page`
1532
+ };
1533
+ }
1534
+ if (options.loggedInIndicator && !bodyText?.includes(options.loggedInIndicator)) {
1535
+ return {
1536
+ success: false,
1537
+ message: `Login uncertain \u2014 "${options.loggedInIndicator}" not found on page`
1538
+ };
1539
+ }
1540
+ const storageState = JSON.stringify(await context.storageState());
1541
+ return {
1542
+ success: true,
1543
+ message: "Login successful",
1544
+ storageState
1545
+ };
1546
+ }
1547
+ async function checkSessionAlive(page, config) {
1548
+ try {
1549
+ const bodyText = await page.textContent("body");
1550
+ if (!bodyText) return true;
1551
+ if (config.loggedOutIndicator && bodyText.includes(config.loggedOutIndicator)) {
1552
+ return false;
1553
+ }
1554
+ if (config.loggedInIndicator && !bodyText.includes(config.loggedInIndicator)) {
1555
+ return false;
1556
+ }
1557
+ return true;
1558
+ } catch {
1559
+ return false;
1560
+ }
1561
+ }
1562
+
1019
1563
  // src/index.ts
1020
1564
  var configSchema = z.object({
1021
1565
  /** Starting URL for recording */
@@ -1088,7 +1632,7 @@ var recorderDriver = {
1088
1632
  },
1089
1633
  async crawl(config, options) {
1090
1634
  const parsedConfig = configSchema.parse(config);
1091
- return crawlAndBuildSessions(
1635
+ const result = await crawlAndBuildSessions(
1092
1636
  {
1093
1637
  startUrl: parsedConfig.startUrl ?? "",
1094
1638
  browser: parsedConfig.browser,
@@ -1097,6 +1641,7 @@ var recorderDriver = {
1097
1641
  },
1098
1642
  options
1099
1643
  );
1644
+ return result.sessions;
1100
1645
  }
1101
1646
  };
1102
1647
  var runnerDriver = {
@@ -1120,11 +1665,16 @@ export {
1120
1665
  BrowserRecorder,
1121
1666
  BrowserRunner,
1122
1667
  BrowserStepSchema,
1668
+ buildCapturedRequests,
1123
1669
  checkBrowsers,
1670
+ checkSessionAlive,
1124
1671
  configSchema,
1125
1672
  crawlAndBuildSessions,
1126
1673
  index_default as default,
1674
+ detectLoginForm,
1675
+ httpScan,
1127
1676
  installBrowsers,
1128
- launchBrowser
1677
+ launchBrowser,
1678
+ performLogin
1129
1679
  };
1130
1680
  //# sourceMappingURL=index.js.map