@vulcn/driver-browser 0.1.2 → 0.3.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,90 +399,79 @@ 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();
412
+ await ctx.options.onPageReady?.(page);
405
413
  const eventFindings = [];
406
414
  let currentPayloadInfo = null;
407
- const dialogHandler = async (dialog) => {
408
- if (currentPayloadInfo) {
409
- const message = dialog.message();
410
- const dialogType = dialog.type();
411
- if (dialogType !== "beforeunload") {
412
- eventFindings.push({
413
- type: "xss",
414
- severity: "high",
415
- title: `XSS Confirmed - ${dialogType}() triggered`,
416
- description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
417
- stepId: currentPayloadInfo.stepId,
418
- payload: currentPayloadInfo.payloadValue,
419
- url: page.url(),
420
- evidence: `Dialog type: ${dialogType}, Message: ${message}`,
421
- metadata: {
422
- dialogType,
423
- dialogMessage: message,
424
- detectionMethod: "dialog"
425
- }
426
- });
427
- }
428
- }
429
- try {
430
- await dialog.dismiss();
431
- } catch {
432
- }
433
- };
434
- const consoleHandler = async (msg) => {
435
- if (currentPayloadInfo && msg.type() === "log") {
436
- const text = msg.text();
437
- if (text.includes("vulcn") || text.includes(currentPayloadInfo.payloadValue)) {
438
- eventFindings.push({
439
- type: "xss",
440
- severity: "high",
441
- title: "XSS Confirmed - Console Output",
442
- description: `JavaScript console.log was triggered by payload injection`,
443
- stepId: currentPayloadInfo.stepId,
444
- payload: currentPayloadInfo.payloadValue,
445
- url: page.url(),
446
- evidence: `Console output: ${text}`,
447
- metadata: {
448
- consoleType: msg.type(),
449
- detectionMethod: "console"
450
- }
451
- });
452
- }
453
- }
454
- };
415
+ const dialogHandler = createDialogHandler(
416
+ page,
417
+ eventFindings,
418
+ () => currentPayloadInfo
419
+ );
420
+ const consoleHandler = createConsoleHandler(
421
+ eventFindings,
422
+ () => currentPayloadInfo
423
+ );
455
424
  page.on("dialog", dialogHandler);
456
425
  page.on("console", consoleHandler);
457
426
  try {
458
427
  const injectableSteps = session.steps.filter(
459
428
  (step) => step.type === "browser.input" && step.injectable !== false
460
429
  );
461
- const allPayloads = [];
462
- for (const payloadSet of payloads) {
463
- for (const value of payloadSet.payloads) {
464
- allPayloads.push({ payloadSet, value });
465
- }
466
- }
430
+ const allPayloads = interleavePayloads(payloads);
431
+ const confirmedTypes = /* @__PURE__ */ new Set();
467
432
  for (const injectableStep of injectableSteps) {
433
+ let isFirstPayload = true;
434
+ let formPageUrl = null;
468
435
  for (const { payloadSet, value } of allPayloads) {
436
+ const stepTypeKey = `${injectableStep.id}::${payloadSet.category}`;
437
+ if (confirmedTypes.has(stepTypeKey)) {
438
+ continue;
439
+ }
469
440
  try {
470
441
  currentPayloadInfo = {
471
442
  stepId: injectableStep.id,
472
443
  payloadSet,
473
444
  payloadValue: value
474
445
  };
475
- await _BrowserRunner.replayWithPayload(
476
- page,
477
- session,
478
- injectableStep,
479
- value,
480
- startUrl
481
- );
482
- 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(
483
475
  page,
484
476
  injectableStep,
485
477
  payloadSet,
@@ -489,14 +481,23 @@ var BrowserRunner = class _BrowserRunner {
489
481
  if (reflectionFinding) {
490
482
  allFindings.push(reflectionFinding);
491
483
  }
484
+ const seenKeys = /* @__PURE__ */ new Set();
492
485
  for (const finding of allFindings) {
493
- ctx.addFinding(finding);
486
+ const dedupKey = `${finding.type}::${finding.stepId}::${finding.title}`;
487
+ if (!seenKeys.has(dedupKey)) {
488
+ seenKeys.add(dedupKey);
489
+ ctx.addFinding(finding);
490
+ }
491
+ }
492
+ if (allFindings.length > 0) {
493
+ confirmedTypes.add(stepTypeKey);
494
494
  }
495
495
  eventFindings.length = 0;
496
496
  payloadsTested++;
497
497
  ctx.options.onStepComplete?.(injectableStep.id, payloadsTested);
498
498
  } catch (err) {
499
499
  errors.push(`${injectableStep.id}: ${String(err)}`);
500
+ isFirstPayload = true;
500
501
  }
501
502
  }
502
503
  }
@@ -504,7 +505,11 @@ var BrowserRunner = class _BrowserRunner {
504
505
  page.off("dialog", dialogHandler);
505
506
  page.off("console", consoleHandler);
506
507
  currentPayloadInfo = null;
507
- await browser.close();
508
+ await ctx.options.onBeforeClose?.(page);
509
+ await context.close();
510
+ if (ownBrowser) {
511
+ await ownBrowser.close();
512
+ }
508
513
  }
509
514
  return {
510
515
  findings: ctx.findings,
@@ -514,136 +519,264 @@ var BrowserRunner = class _BrowserRunner {
514
519
  errors
515
520
  };
516
521
  }
517
- /**
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.
524
- */
525
- static async replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
526
- await page.goto(startUrl, { waitUntil: "domcontentloaded" });
527
- let injected = false;
528
- for (const step of session.steps) {
529
- const browserStep = step;
530
- try {
531
- switch (browserStep.type) {
532
- case "browser.navigate":
533
- if (injected && browserStep.url.includes("sid=")) {
534
- continue;
535
- }
536
- await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
537
- break;
538
- case "browser.click":
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
- }
551
- break;
552
- case "browser.input": {
553
- const value = step.id === targetStep.id ? payloadValue : browserStep.value;
554
- await page.fill(browserStep.selector, value, { timeout: 5e3 });
555
- if (step.id === targetStep.id) {
556
- injected = true;
557
- }
558
- 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"
559
543
  }
560
- case "browser.keypress": {
561
- const modifiers = browserStep.modifiers ?? [];
562
- for (const mod of modifiers) {
563
- await page.keyboard.down(
564
- mod
565
- );
566
- }
567
- await page.keyboard.press(browserStep.key);
568
- for (const mod of modifiers.reverse()) {
569
- await page.keyboard.up(
570
- mod
571
- );
572
- }
573
- 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"
574
571
  }
575
- case "browser.scroll":
576
- if (browserStep.selector) {
577
- await page.locator(browserStep.selector).evaluate((el, pos) => {
578
- el.scrollTo(pos.x, pos.y);
579
- }, browserStep.position);
580
- } else {
581
- await page.evaluate((pos) => {
582
- window.scrollTo(pos.x, pos.y);
583
- }, browserStep.position);
584
- }
585
- break;
586
- case "browser.wait":
587
- await page.waitForTimeout(browserStep.duration);
588
- break;
589
- }
590
- } 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;
591
590
  }
592
591
  }
592
+ await page.fill(targetSelector, payloadValue, { timeout: 5e3 });
593
+ await replayStepsAfter(page, session, targetStep);
593
594
  await page.waitForTimeout(500);
595
+ return true;
596
+ } catch {
597
+ return false;
594
598
  }
595
- /**
596
- * Check for payload reflection in page content
597
- */
598
- static async checkReflection(page, step, payloadSet, payloadValue) {
599
- const content = await page.content();
600
- for (const pattern of payloadSet.detectPatterns) {
601
- if (pattern.test(content)) {
602
- return {
603
- type: payloadSet.category,
604
- severity: _BrowserRunner.getSeverity(payloadSet.category),
605
- title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
606
- description: `Payload pattern was reflected in page content`,
607
- stepId: step.id,
608
- payload: payloadValue,
609
- url: page.url(),
610
- evidence: content.match(pattern)?.[0]?.slice(0, 200)
611
- };
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;
612
662
  }
663
+ } catch {
613
664
  }
614
- if (content.includes(payloadValue)) {
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 {
716
+ }
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)) {
615
724
  return {
616
725
  type: payloadSet.category,
617
- severity: "medium",
618
- title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
619
- 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`,
620
729
  stepId: step.id,
621
730
  payload: payloadValue,
622
- url: page.url()
731
+ url: page.url(),
732
+ evidence: content.match(pattern)?.[0]?.slice(0, 200)
623
733
  };
624
734
  }
625
- return void 0;
626
735
  }
627
- /**
628
- * Determine severity based on vulnerability category
629
- */
630
- static getSeverity(category) {
631
- switch (category) {
632
- case "sqli":
633
- case "command-injection":
634
- case "xxe":
635
- return "critical";
636
- case "xss":
637
- case "ssrf":
638
- case "path-traversal":
639
- return "high";
640
- case "open-redirect":
641
- return "medium";
642
- default:
643
- 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
+ }
644
776
  }
645
777
  }
646
- };
778
+ return result;
779
+ }
647
780
 
648
781
  // src/crawler.ts
649
782
  var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
@@ -680,7 +813,8 @@ async function crawlAndBuildSessions(config, options = {}) {
680
813
  headless: config.headless ?? true
681
814
  });
682
815
  const context = await browser.newContext({
683
- viewport: config.viewport ?? { width: 1280, height: 720 }
816
+ viewport: config.viewport ?? { width: 1280, height: 720 },
817
+ ...options.storageState ? { storageState: JSON.parse(options.storageState) } : {}
684
818
  });
685
819
  try {
686
820
  while (queue.length > 0 && visited.size < opts.maxPages) {
@@ -863,20 +997,58 @@ async function discoverForms(page, pageUrl) {
863
997
  }
864
998
  return forms;
865
999
  }
1000
+ var REDIRECT_PARAMS = /* @__PURE__ */ new Set([
1001
+ "to",
1002
+ "url",
1003
+ "redirect",
1004
+ "redirect_uri",
1005
+ "redirect_url",
1006
+ "return",
1007
+ "return_url",
1008
+ "returnto",
1009
+ "next",
1010
+ "goto",
1011
+ "dest",
1012
+ "destination",
1013
+ "continue",
1014
+ "target",
1015
+ "rurl",
1016
+ "out",
1017
+ "link",
1018
+ "forward"
1019
+ ]);
1020
+ function isExternalRedirectLink(link, origin) {
1021
+ try {
1022
+ const parsed = new URL(link);
1023
+ if (parsed.origin !== origin) return false;
1024
+ for (const [key, value] of parsed.searchParams) {
1025
+ if (REDIRECT_PARAMS.has(key.toLowerCase())) {
1026
+ try {
1027
+ const targetUrl = new URL(value);
1028
+ if (targetUrl.origin !== origin) return true;
1029
+ } catch {
1030
+ }
1031
+ }
1032
+ }
1033
+ return false;
1034
+ } catch {
1035
+ return false;
1036
+ }
1037
+ }
866
1038
  async function discoverLinks(page, origin, sameOrigin) {
867
1039
  const links = await page.evaluate(() => {
868
1040
  return Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"));
869
1041
  });
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;
1042
+ return links.filter((link) => {
1043
+ try {
1044
+ const linkOrigin = new URL(link).origin;
1045
+ if (sameOrigin && linkOrigin !== origin) return false;
1046
+ if (isExternalRedirectLink(link, origin)) return false;
1047
+ return true;
1048
+ } catch {
1049
+ return false;
1050
+ }
1051
+ });
880
1052
  }
881
1053
  function buildSessions(forms) {
882
1054
  const targetForms = forms.filter((f) => f.inputs.some((i) => i.injectable));
@@ -957,6 +1129,182 @@ function normalizeUrl(url) {
957
1129
  }
958
1130
  }
959
1131
 
1132
+ // src/auth.ts
1133
+ async function detectLoginForm(page) {
1134
+ return page.evaluate(() => {
1135
+ function findUsernameInput(container) {
1136
+ const selectors = [
1137
+ 'input[autocomplete="username"]',
1138
+ 'input[autocomplete="email"]',
1139
+ 'input[type="email"]',
1140
+ 'input[name*="user" i]',
1141
+ 'input[name*="login" i]',
1142
+ 'input[name*="email" i]',
1143
+ 'input[id*="user" i]',
1144
+ 'input[id*="login" i]',
1145
+ 'input[id*="email" i]',
1146
+ 'input[name*="name" i]',
1147
+ 'input[type="text"]'
1148
+ ];
1149
+ for (const sel of selectors) {
1150
+ const el = container.querySelector(sel);
1151
+ if (el && el.type !== "password" && el.type !== "hidden") {
1152
+ return el;
1153
+ }
1154
+ }
1155
+ return null;
1156
+ }
1157
+ function findSubmitButton(container) {
1158
+ const selectors = [
1159
+ 'button[type="submit"]',
1160
+ 'input[type="submit"]',
1161
+ "button:not([type])",
1162
+ 'button[type="button"]'
1163
+ ];
1164
+ for (const sel of selectors) {
1165
+ const el = container.querySelector(sel);
1166
+ if (el) return el;
1167
+ }
1168
+ return null;
1169
+ }
1170
+ function getSelector(el) {
1171
+ if (el.id) return `#${CSS.escape(el.id)}`;
1172
+ if (el.getAttribute("name"))
1173
+ return `${el.tagName.toLowerCase()}[name="${CSS.escape(el.getAttribute("name"))}"]`;
1174
+ if (el.getAttribute("type") && el.tagName === "INPUT")
1175
+ return `input[type="${el.getAttribute("type")}"]`;
1176
+ const parent = el.parentElement;
1177
+ if (!parent) return el.tagName.toLowerCase();
1178
+ const siblings = Array.from(parent.children);
1179
+ const index = siblings.indexOf(el) + 1;
1180
+ return `${parent.tagName.toLowerCase()} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
1181
+ }
1182
+ const forms = document.querySelectorAll("form");
1183
+ for (const form of forms) {
1184
+ const passwordInput2 = form.querySelector(
1185
+ 'input[type="password"]'
1186
+ );
1187
+ if (!passwordInput2) continue;
1188
+ const usernameInput = findUsernameInput(form);
1189
+ const submitButton = findSubmitButton(form);
1190
+ return {
1191
+ usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
1192
+ passwordSelector: getSelector(passwordInput2),
1193
+ submitSelector: submitButton ? getSelector(submitButton) : null,
1194
+ autoDetected: true
1195
+ };
1196
+ }
1197
+ const passwordInput = document.querySelector(
1198
+ 'input[type="password"]'
1199
+ );
1200
+ if (passwordInput) {
1201
+ const usernameInput = findUsernameInput(document.body);
1202
+ const submitButton = findSubmitButton(document.body);
1203
+ return {
1204
+ usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
1205
+ passwordSelector: getSelector(passwordInput),
1206
+ submitSelector: submitButton ? getSelector(submitButton) : null,
1207
+ autoDetected: true
1208
+ };
1209
+ }
1210
+ return null;
1211
+ });
1212
+ }
1213
+ async function performLogin(page, context, credentials, options) {
1214
+ const loginUrl = credentials.loginUrl ?? options.targetUrl;
1215
+ await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 15e3 });
1216
+ let usernameSelector;
1217
+ let passwordSelector;
1218
+ let submitSelector;
1219
+ if (credentials.userSelector && credentials.passSelector) {
1220
+ usernameSelector = credentials.userSelector;
1221
+ passwordSelector = credentials.passSelector;
1222
+ submitSelector = null;
1223
+ } else {
1224
+ const form = await detectLoginForm(page);
1225
+ if (!form) {
1226
+ return {
1227
+ success: false,
1228
+ message: `No login form detected on ${loginUrl}. Use --user-field and --pass-field to specify selectors.`
1229
+ };
1230
+ }
1231
+ usernameSelector = form.usernameSelector;
1232
+ passwordSelector = form.passwordSelector;
1233
+ submitSelector = form.submitSelector;
1234
+ }
1235
+ try {
1236
+ await page.fill(usernameSelector, credentials.username, { timeout: 5e3 });
1237
+ } catch {
1238
+ return {
1239
+ success: false,
1240
+ message: `Could not find username field: ${usernameSelector}`
1241
+ };
1242
+ }
1243
+ try {
1244
+ await page.fill(passwordSelector, credentials.password, { timeout: 5e3 });
1245
+ } catch {
1246
+ return {
1247
+ success: false,
1248
+ message: `Could not find password field: ${passwordSelector}`
1249
+ };
1250
+ }
1251
+ try {
1252
+ if (submitSelector) {
1253
+ await Promise.all([
1254
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
1255
+ }),
1256
+ page.click(submitSelector, { timeout: 5e3 })
1257
+ ]);
1258
+ } else {
1259
+ await Promise.all([
1260
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
1261
+ }),
1262
+ page.press(passwordSelector, "Enter")
1263
+ ]);
1264
+ }
1265
+ } catch {
1266
+ return {
1267
+ success: false,
1268
+ message: "Failed to submit login form"
1269
+ };
1270
+ }
1271
+ await page.waitForTimeout(1e3);
1272
+ const bodyText = await page.textContent("body").catch(() => "");
1273
+ if (options.loggedOutIndicator && bodyText?.includes(options.loggedOutIndicator)) {
1274
+ return {
1275
+ success: false,
1276
+ message: `Login failed \u2014 "${options.loggedOutIndicator}" still visible on page`
1277
+ };
1278
+ }
1279
+ if (options.loggedInIndicator && !bodyText?.includes(options.loggedInIndicator)) {
1280
+ return {
1281
+ success: false,
1282
+ message: `Login uncertain \u2014 "${options.loggedInIndicator}" not found on page`
1283
+ };
1284
+ }
1285
+ const storageState = JSON.stringify(await context.storageState());
1286
+ return {
1287
+ success: true,
1288
+ message: "Login successful",
1289
+ storageState
1290
+ };
1291
+ }
1292
+ async function checkSessionAlive(page, config) {
1293
+ try {
1294
+ const bodyText = await page.textContent("body");
1295
+ if (!bodyText) return true;
1296
+ if (config.loggedOutIndicator && bodyText.includes(config.loggedOutIndicator)) {
1297
+ return false;
1298
+ }
1299
+ if (config.loggedInIndicator && !bodyText.includes(config.loggedInIndicator)) {
1300
+ return false;
1301
+ }
1302
+ return true;
1303
+ } catch {
1304
+ return false;
1305
+ }
1306
+ }
1307
+
960
1308
  // src/index.ts
961
1309
  var configSchema = z.object({
962
1310
  /** Starting URL for recording */
@@ -1062,10 +1410,13 @@ export {
1062
1410
  BrowserRunner,
1063
1411
  BrowserStepSchema,
1064
1412
  checkBrowsers,
1413
+ checkSessionAlive,
1065
1414
  configSchema,
1066
1415
  crawlAndBuildSessions,
1067
1416
  index_default as default,
1417
+ detectLoginForm,
1068
1418
  installBrowsers,
1069
- launchBrowser
1419
+ launchBrowser,
1420
+ performLogin
1070
1421
  };
1071
1422
  //# sourceMappingURL=index.js.map