@vulcn/driver-browser 0.1.0 → 0.1.2

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
@@ -407,18 +407,20 @@ var BrowserRunner = class _BrowserRunner {
407
407
  const dialogHandler = async (dialog) => {
408
408
  if (currentPayloadInfo) {
409
409
  const message = dialog.message();
410
- if (message.includes("vulcn") || message === currentPayloadInfo.payloadValue) {
410
+ const dialogType = dialog.type();
411
+ if (dialogType !== "beforeunload") {
411
412
  eventFindings.push({
412
413
  type: "xss",
413
414
  severity: "high",
414
- title: "XSS Confirmed - Dialog Triggered",
415
- description: `JavaScript dialog was triggered by payload injection`,
415
+ title: `XSS Confirmed - ${dialogType}() triggered`,
416
+ description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
416
417
  stepId: currentPayloadInfo.stepId,
417
418
  payload: currentPayloadInfo.payloadValue,
418
419
  url: page.url(),
419
- evidence: `Dialog message: ${message}`,
420
+ evidence: `Dialog type: ${dialogType}, Message: ${message}`,
420
421
  metadata: {
421
- dialogType: dialog.type(),
422
+ dialogType,
423
+ dialogMessage: message,
422
424
  detectionMethod: "dialog"
423
425
  }
424
426
  });
@@ -514,22 +516,45 @@ var BrowserRunner = class _BrowserRunner {
514
516
  }
515
517
  /**
516
518
  * Replay session steps with payload injected at target step
519
+ *
520
+ * IMPORTANT: We replay ALL steps, not just up to the injectable step.
521
+ * The injection replaces the input value, but subsequent steps (like
522
+ * clicking submit) must still execute so the payload reaches the server
523
+ * and gets reflected back in the response.
517
524
  */
518
525
  static async replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
519
526
  await page.goto(startUrl, { waitUntil: "domcontentloaded" });
527
+ let injected = false;
520
528
  for (const step of session.steps) {
521
529
  const browserStep = step;
522
530
  try {
523
531
  switch (browserStep.type) {
524
532
  case "browser.navigate":
533
+ if (injected && browserStep.url.includes("sid=")) {
534
+ continue;
535
+ }
525
536
  await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
526
537
  break;
527
538
  case "browser.click":
528
- await page.click(browserStep.selector, { timeout: 5e3 });
539
+ if (injected) {
540
+ await Promise.all([
541
+ page.waitForNavigation({
542
+ waitUntil: "domcontentloaded",
543
+ timeout: 5e3
544
+ }).catch(() => {
545
+ }),
546
+ page.click(browserStep.selector, { timeout: 5e3 })
547
+ ]);
548
+ } else {
549
+ await page.click(browserStep.selector, { timeout: 5e3 });
550
+ }
529
551
  break;
530
552
  case "browser.input": {
531
553
  const value = step.id === targetStep.id ? payloadValue : browserStep.value;
532
554
  await page.fill(browserStep.selector, value, { timeout: 5e3 });
555
+ if (step.id === targetStep.id) {
556
+ injected = true;
557
+ }
533
558
  break;
534
559
  }
535
560
  case "browser.keypress": {
@@ -564,11 +589,8 @@ var BrowserRunner = class _BrowserRunner {
564
589
  }
565
590
  } catch {
566
591
  }
567
- if (step.id === targetStep.id) {
568
- await page.waitForTimeout(100);
569
- break;
570
- }
571
592
  }
593
+ await page.waitForTimeout(500);
572
594
  }
573
595
  /**
574
596
  * Check for payload reflection in page content
@@ -623,6 +645,318 @@ var BrowserRunner = class _BrowserRunner {
623
645
  }
624
646
  };
625
647
 
648
+ // src/crawler.ts
649
+ var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
650
+ "text",
651
+ "search",
652
+ "url",
653
+ "email",
654
+ "tel",
655
+ "password",
656
+ "textarea",
657
+ ""
658
+ ]);
659
+ var CRAWL_DEFAULTS = {
660
+ maxDepth: 2,
661
+ maxPages: 20,
662
+ pageTimeout: 1e4,
663
+ sameOrigin: true
664
+ };
665
+ async function crawlAndBuildSessions(config, options = {}) {
666
+ const opts = { ...CRAWL_DEFAULTS, ...options };
667
+ const startUrl = config.startUrl;
668
+ let normalizedUrl;
669
+ try {
670
+ normalizedUrl = new URL(startUrl);
671
+ } catch {
672
+ throw new Error(`Invalid URL: ${startUrl}`);
673
+ }
674
+ const origin = normalizedUrl.origin;
675
+ const visited = /* @__PURE__ */ new Set();
676
+ const allForms = [];
677
+ const queue = [[normalizedUrl.href, 0]];
678
+ const { browser } = await launchBrowser({
679
+ browser: config.browser ?? "chromium",
680
+ headless: config.headless ?? true
681
+ });
682
+ const context = await browser.newContext({
683
+ viewport: config.viewport ?? { width: 1280, height: 720 }
684
+ });
685
+ try {
686
+ while (queue.length > 0 && visited.size < opts.maxPages) {
687
+ const [url, depth] = queue.shift();
688
+ const normalizedPageUrl = normalizeUrl(url);
689
+ if (visited.has(normalizedPageUrl)) continue;
690
+ visited.add(normalizedPageUrl);
691
+ console.log(`[crawler] [depth=${depth}] Crawling: ${normalizedPageUrl}`);
692
+ const page = await context.newPage();
693
+ try {
694
+ await page.goto(normalizedPageUrl, {
695
+ waitUntil: "domcontentloaded",
696
+ timeout: opts.pageTimeout
697
+ });
698
+ await page.waitForTimeout(1e3);
699
+ const forms = await discoverForms(page, normalizedPageUrl);
700
+ allForms.push(...forms);
701
+ const injectableCount = forms.reduce(
702
+ (s, f) => s + f.inputs.filter((i) => i.injectable).length,
703
+ 0
704
+ );
705
+ console.log(
706
+ `[crawler] Found ${forms.length} form(s), ${injectableCount} injectable input(s)`
707
+ );
708
+ opts.onPageCrawled?.(normalizedPageUrl, forms.length);
709
+ if (depth < opts.maxDepth) {
710
+ const links = await discoverLinks(page, origin, opts.sameOrigin);
711
+ for (const link of links) {
712
+ const normalizedLink = normalizeUrl(link);
713
+ if (!visited.has(normalizedLink)) {
714
+ queue.push([normalizedLink, depth + 1]);
715
+ }
716
+ }
717
+ console.log(`[crawler] Found ${links.length} link(s) to follow`);
718
+ }
719
+ } catch (err) {
720
+ console.warn(
721
+ `[crawler] Failed: ${err instanceof Error ? err.message : String(err)}`
722
+ );
723
+ } finally {
724
+ await page.close();
725
+ }
726
+ }
727
+ } finally {
728
+ await browser.close();
729
+ }
730
+ console.log(
731
+ `[crawler] Complete: ${visited.size} page(s), ${allForms.length} form(s)`
732
+ );
733
+ return buildSessions(allForms);
734
+ }
735
+ async function discoverForms(page, pageUrl) {
736
+ const forms = [];
737
+ const explicitForms = await page.evaluate(() => {
738
+ const results = [];
739
+ const formElements = document.querySelectorAll("form");
740
+ formElements.forEach((form, formIndex) => {
741
+ const inputs = [];
742
+ const inputEls = form.querySelectorAll(
743
+ 'input, textarea, [contenteditable="true"]'
744
+ );
745
+ inputEls.forEach((input, inputIndex) => {
746
+ const el = input;
747
+ const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
748
+ const name = el.name || el.id || `input-${inputIndex}`;
749
+ let selector = "";
750
+ if (el.id) {
751
+ selector = `#${CSS.escape(el.id)}`;
752
+ } else if (el.name) {
753
+ selector = `form:nth-of-type(${formIndex + 1}) [name="${CSS.escape(el.name)}"]`;
754
+ } else {
755
+ selector = `form:nth-of-type(${formIndex + 1}) ${el.tagName.toLowerCase()}:nth-of-type(${inputIndex + 1})`;
756
+ }
757
+ inputs.push({
758
+ selector,
759
+ type,
760
+ name,
761
+ placeholder: el.placeholder || ""
762
+ });
763
+ });
764
+ let submitSelector = null;
765
+ const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]') || form.querySelector("button:not([type])") || form.querySelector('button, input[type="button"]');
766
+ if (submitBtn) {
767
+ const btn = submitBtn;
768
+ if (btn.id) {
769
+ submitSelector = `#${CSS.escape(btn.id)}`;
770
+ } else {
771
+ const tag = btn.tagName.toLowerCase();
772
+ const type = btn.getAttribute("type");
773
+ if (type) {
774
+ submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}[type="${type}"]`;
775
+ } else {
776
+ submitSelector = `form:nth-of-type(${formIndex + 1}) ${tag}`;
777
+ }
778
+ }
779
+ }
780
+ results.push({
781
+ formIndex,
782
+ action: form.action || "",
783
+ method: (form.method || "GET").toUpperCase(),
784
+ inputs,
785
+ submitSelector
786
+ });
787
+ });
788
+ return results;
789
+ });
790
+ for (const form of explicitForms) {
791
+ if (form.inputs.length === 0) continue;
792
+ forms.push({
793
+ pageUrl,
794
+ formSelector: `form:nth-of-type(${form.formIndex + 1})`,
795
+ action: form.action,
796
+ method: form.method,
797
+ inputs: form.inputs.map((input) => ({
798
+ selector: input.selector,
799
+ type: input.type,
800
+ name: input.name,
801
+ injectable: INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase()),
802
+ placeholder: input.placeholder || void 0
803
+ })),
804
+ submitSelector: form.submitSelector
805
+ });
806
+ }
807
+ const standaloneInputs = await page.evaluate(() => {
808
+ const results = [];
809
+ const allInputs = document.querySelectorAll(
810
+ 'input:not(form input), textarea:not(form textarea), [contenteditable="true"]:not(form [contenteditable])'
811
+ );
812
+ allInputs.forEach((input) => {
813
+ const el = input;
814
+ const type = el.tagName.toLowerCase() === "textarea" ? "textarea" : el.getAttribute("type") || "text";
815
+ const name = el.name || el.id || "";
816
+ let selector = "";
817
+ if (el.id) {
818
+ selector = `#${CSS.escape(el.id)}`;
819
+ } else if (el.name) {
820
+ selector = `[name="${CSS.escape(el.name)}"]`;
821
+ } else {
822
+ selector = `${el.tagName.toLowerCase()}[type="${type}"]`;
823
+ }
824
+ let nearbyButtonSelector = null;
825
+ const parent = el.parentElement;
826
+ if (parent) {
827
+ const btn = parent.querySelector("button") || parent.querySelector('input[type="submit"]') || parent.querySelector('input[type="button"]');
828
+ if (btn) {
829
+ const btnEl = btn;
830
+ if (btnEl.id) {
831
+ nearbyButtonSelector = `#${CSS.escape(btnEl.id)}`;
832
+ }
833
+ }
834
+ }
835
+ results.push({
836
+ selector,
837
+ type,
838
+ name,
839
+ placeholder: el.placeholder || "",
840
+ nearbyButtonSelector
841
+ });
842
+ });
843
+ return results;
844
+ });
845
+ for (const input of standaloneInputs) {
846
+ if (!INJECTABLE_INPUT_TYPES.has(input.type.toLowerCase())) continue;
847
+ forms.push({
848
+ pageUrl,
849
+ formSelector: "(standalone)",
850
+ action: pageUrl,
851
+ method: "GET",
852
+ inputs: [
853
+ {
854
+ selector: input.selector,
855
+ type: input.type,
856
+ name: input.name,
857
+ injectable: true,
858
+ placeholder: input.placeholder || void 0
859
+ }
860
+ ],
861
+ submitSelector: input.nearbyButtonSelector
862
+ });
863
+ }
864
+ return forms;
865
+ }
866
+ async function discoverLinks(page, origin, sameOrigin) {
867
+ const links = await page.evaluate(() => {
868
+ return Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"));
869
+ });
870
+ if (sameOrigin) {
871
+ return links.filter((link) => {
872
+ try {
873
+ return new URL(link).origin === origin;
874
+ } catch {
875
+ return false;
876
+ }
877
+ });
878
+ }
879
+ return links;
880
+ }
881
+ function buildSessions(forms) {
882
+ const targetForms = forms.filter((f) => f.inputs.some((i) => i.injectable));
883
+ return targetForms.map((form, idx) => buildSessionForForm(form, idx));
884
+ }
885
+ function buildSessionForForm(form, index) {
886
+ const steps = [];
887
+ let stepNum = 1;
888
+ steps.push({
889
+ id: `step-${stepNum++}`,
890
+ type: "browser.navigate",
891
+ url: form.pageUrl,
892
+ timestamp: Date.now()
893
+ });
894
+ const injectableInputs = form.inputs.filter((i) => i.injectable);
895
+ for (const input of injectableInputs) {
896
+ steps.push({
897
+ id: `step-${stepNum++}`,
898
+ type: "browser.input",
899
+ selector: input.selector,
900
+ value: "test",
901
+ injectable: true,
902
+ timestamp: Date.now() + stepNum * 100
903
+ });
904
+ }
905
+ if (form.submitSelector) {
906
+ steps.push({
907
+ id: `step-${stepNum++}`,
908
+ type: "browser.click",
909
+ selector: form.submitSelector,
910
+ timestamp: Date.now() + stepNum * 100
911
+ });
912
+ } else {
913
+ steps.push({
914
+ id: `step-${stepNum++}`,
915
+ type: "browser.keypress",
916
+ key: "Enter",
917
+ timestamp: Date.now() + stepNum * 100
918
+ });
919
+ }
920
+ const inputNames = injectableInputs.map((i) => i.name || i.type).join(", ");
921
+ const pagePath = (() => {
922
+ try {
923
+ return new URL(form.pageUrl).pathname;
924
+ } catch {
925
+ return form.pageUrl;
926
+ }
927
+ })();
928
+ return {
929
+ name: `Crawl: ${pagePath} \u2014 form ${index + 1} (${inputNames})`,
930
+ driver: "browser",
931
+ driverConfig: {
932
+ startUrl: form.pageUrl,
933
+ browser: "chromium",
934
+ headless: true,
935
+ viewport: { width: 1280, height: 720 }
936
+ },
937
+ steps,
938
+ metadata: {
939
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
940
+ version: "0.3.0",
941
+ source: "crawler",
942
+ formAction: form.action,
943
+ formMethod: form.method
944
+ }
945
+ };
946
+ }
947
+ function normalizeUrl(url) {
948
+ try {
949
+ const parsed = new URL(url);
950
+ parsed.hash = "";
951
+ if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) {
952
+ parsed.pathname = parsed.pathname.slice(0, -1);
953
+ }
954
+ return parsed.href;
955
+ } catch {
956
+ return url;
957
+ }
958
+ }
959
+
626
960
  // src/index.ts
627
961
  var configSchema = z.object({
628
962
  /** Starting URL for recording */
@@ -692,6 +1026,18 @@ var recorderDriver = {
692
1026
  async start(config, options) {
693
1027
  const parsedConfig = configSchema.parse(config);
694
1028
  return BrowserRecorder.start(parsedConfig, options);
1029
+ },
1030
+ async crawl(config, options) {
1031
+ const parsedConfig = configSchema.parse(config);
1032
+ return crawlAndBuildSessions(
1033
+ {
1034
+ startUrl: parsedConfig.startUrl ?? "",
1035
+ browser: parsedConfig.browser,
1036
+ headless: parsedConfig.headless,
1037
+ viewport: parsedConfig.viewport
1038
+ },
1039
+ options
1040
+ );
695
1041
  }
696
1042
  };
697
1043
  var runnerDriver = {
@@ -717,6 +1063,7 @@ export {
717
1063
  BrowserStepSchema,
718
1064
  checkBrowsers,
719
1065
  configSchema,
1066
+ crawlAndBuildSessions,
720
1067
  index_default as default,
721
1068
  installBrowsers,
722
1069
  launchBrowser