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