@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.cjs +551 -197
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -21
- package/dist/index.d.ts +90 -21
- package/dist/index.js +547 -196
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -25,11 +25,14 @@ __export(index_exports, {
|
|
|
25
25
|
BrowserRunner: () => BrowserRunner,
|
|
26
26
|
BrowserStepSchema: () => BrowserStepSchema,
|
|
27
27
|
checkBrowsers: () => checkBrowsers,
|
|
28
|
+
checkSessionAlive: () => checkSessionAlive,
|
|
28
29
|
configSchema: () => configSchema,
|
|
29
30
|
crawlAndBuildSessions: () => crawlAndBuildSessions,
|
|
30
31
|
default: () => index_default,
|
|
32
|
+
detectLoginForm: () => detectLoginForm,
|
|
31
33
|
installBrowsers: () => installBrowsers,
|
|
32
|
-
launchBrowser: () => launchBrowser
|
|
34
|
+
launchBrowser: () => launchBrowser,
|
|
35
|
+
performLogin: () => performLogin
|
|
33
36
|
});
|
|
34
37
|
module.exports = __toCommonJS(index_exports);
|
|
35
38
|
var import_zod = require("zod");
|
|
@@ -401,9 +404,12 @@ var BrowserRecorder = class _BrowserRecorder {
|
|
|
401
404
|
};
|
|
402
405
|
|
|
403
406
|
// src/runner.ts
|
|
404
|
-
var BrowserRunner = class
|
|
407
|
+
var BrowserRunner = class {
|
|
405
408
|
/**
|
|
406
|
-
* Execute a session with security payloads
|
|
409
|
+
* Execute a session with security payloads.
|
|
410
|
+
*
|
|
411
|
+
* If ctx.options.browser is provided, reuses that browser (persistent mode).
|
|
412
|
+
* Otherwise, launches and closes its own browser (standalone mode).
|
|
407
413
|
*/
|
|
408
414
|
static async execute(session, ctx) {
|
|
409
415
|
const config = session.driverConfig;
|
|
@@ -429,90 +435,79 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
429
435
|
]
|
|
430
436
|
};
|
|
431
437
|
}
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
438
|
+
const sharedBrowser = ctx.options.browser;
|
|
439
|
+
const ownBrowser = sharedBrowser ? null : (await launchBrowser({ browser: browserType, headless })).browser;
|
|
440
|
+
const browser = sharedBrowser ?? ownBrowser;
|
|
441
|
+
const storageState = ctx.options.storageState;
|
|
442
|
+
const contextOptions = { viewport };
|
|
443
|
+
if (storageState) {
|
|
444
|
+
contextOptions.storageState = JSON.parse(storageState);
|
|
445
|
+
}
|
|
446
|
+
const context = await browser.newContext(contextOptions);
|
|
437
447
|
const page = await context.newPage();
|
|
448
|
+
await ctx.options.onPageReady?.(page);
|
|
438
449
|
const eventFindings = [];
|
|
439
450
|
let currentPayloadInfo = null;
|
|
440
|
-
const dialogHandler =
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
|
|
450
|
-
stepId: currentPayloadInfo.stepId,
|
|
451
|
-
payload: currentPayloadInfo.payloadValue,
|
|
452
|
-
url: page.url(),
|
|
453
|
-
evidence: `Dialog type: ${dialogType}, Message: ${message}`,
|
|
454
|
-
metadata: {
|
|
455
|
-
dialogType,
|
|
456
|
-
dialogMessage: message,
|
|
457
|
-
detectionMethod: "dialog"
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
try {
|
|
463
|
-
await dialog.dismiss();
|
|
464
|
-
} catch {
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
const consoleHandler = async (msg) => {
|
|
468
|
-
if (currentPayloadInfo && msg.type() === "log") {
|
|
469
|
-
const text = msg.text();
|
|
470
|
-
if (text.includes("vulcn") || text.includes(currentPayloadInfo.payloadValue)) {
|
|
471
|
-
eventFindings.push({
|
|
472
|
-
type: "xss",
|
|
473
|
-
severity: "high",
|
|
474
|
-
title: "XSS Confirmed - Console Output",
|
|
475
|
-
description: `JavaScript console.log was triggered by payload injection`,
|
|
476
|
-
stepId: currentPayloadInfo.stepId,
|
|
477
|
-
payload: currentPayloadInfo.payloadValue,
|
|
478
|
-
url: page.url(),
|
|
479
|
-
evidence: `Console output: ${text}`,
|
|
480
|
-
metadata: {
|
|
481
|
-
consoleType: msg.type(),
|
|
482
|
-
detectionMethod: "console"
|
|
483
|
-
}
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
};
|
|
451
|
+
const dialogHandler = createDialogHandler(
|
|
452
|
+
page,
|
|
453
|
+
eventFindings,
|
|
454
|
+
() => currentPayloadInfo
|
|
455
|
+
);
|
|
456
|
+
const consoleHandler = createConsoleHandler(
|
|
457
|
+
eventFindings,
|
|
458
|
+
() => currentPayloadInfo
|
|
459
|
+
);
|
|
488
460
|
page.on("dialog", dialogHandler);
|
|
489
461
|
page.on("console", consoleHandler);
|
|
490
462
|
try {
|
|
491
463
|
const injectableSteps = session.steps.filter(
|
|
492
464
|
(step) => step.type === "browser.input" && step.injectable !== false
|
|
493
465
|
);
|
|
494
|
-
const allPayloads =
|
|
495
|
-
|
|
496
|
-
for (const value of payloadSet.payloads) {
|
|
497
|
-
allPayloads.push({ payloadSet, value });
|
|
498
|
-
}
|
|
499
|
-
}
|
|
466
|
+
const allPayloads = interleavePayloads(payloads);
|
|
467
|
+
const confirmedTypes = /* @__PURE__ */ new Set();
|
|
500
468
|
for (const injectableStep of injectableSteps) {
|
|
469
|
+
let isFirstPayload = true;
|
|
470
|
+
let formPageUrl = null;
|
|
501
471
|
for (const { payloadSet, value } of allPayloads) {
|
|
472
|
+
const stepTypeKey = `${injectableStep.id}::${payloadSet.category}`;
|
|
473
|
+
if (confirmedTypes.has(stepTypeKey)) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
502
476
|
try {
|
|
503
477
|
currentPayloadInfo = {
|
|
504
478
|
stepId: injectableStep.id,
|
|
505
479
|
payloadSet,
|
|
506
480
|
payloadValue: value
|
|
507
481
|
};
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
482
|
+
if (isFirstPayload) {
|
|
483
|
+
await replayWithPayload(
|
|
484
|
+
page,
|
|
485
|
+
session,
|
|
486
|
+
injectableStep,
|
|
487
|
+
value,
|
|
488
|
+
startUrl
|
|
489
|
+
);
|
|
490
|
+
isFirstPayload = false;
|
|
491
|
+
formPageUrl = startUrl;
|
|
492
|
+
} else {
|
|
493
|
+
const cycled = await cyclePayload(
|
|
494
|
+
page,
|
|
495
|
+
session,
|
|
496
|
+
injectableStep,
|
|
497
|
+
value,
|
|
498
|
+
formPageUrl ?? startUrl
|
|
499
|
+
);
|
|
500
|
+
if (!cycled) {
|
|
501
|
+
await replayWithPayload(
|
|
502
|
+
page,
|
|
503
|
+
session,
|
|
504
|
+
injectableStep,
|
|
505
|
+
value,
|
|
506
|
+
startUrl
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const reflectionFinding = await checkReflection(
|
|
516
511
|
page,
|
|
517
512
|
injectableStep,
|
|
518
513
|
payloadSet,
|
|
@@ -522,14 +517,23 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
522
517
|
if (reflectionFinding) {
|
|
523
518
|
allFindings.push(reflectionFinding);
|
|
524
519
|
}
|
|
520
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
525
521
|
for (const finding of allFindings) {
|
|
526
|
-
|
|
522
|
+
const dedupKey = `${finding.type}::${finding.stepId}::${finding.title}`;
|
|
523
|
+
if (!seenKeys.has(dedupKey)) {
|
|
524
|
+
seenKeys.add(dedupKey);
|
|
525
|
+
ctx.addFinding(finding);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (allFindings.length > 0) {
|
|
529
|
+
confirmedTypes.add(stepTypeKey);
|
|
527
530
|
}
|
|
528
531
|
eventFindings.length = 0;
|
|
529
532
|
payloadsTested++;
|
|
530
533
|
ctx.options.onStepComplete?.(injectableStep.id, payloadsTested);
|
|
531
534
|
} catch (err) {
|
|
532
535
|
errors.push(`${injectableStep.id}: ${String(err)}`);
|
|
536
|
+
isFirstPayload = true;
|
|
533
537
|
}
|
|
534
538
|
}
|
|
535
539
|
}
|
|
@@ -537,7 +541,11 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
537
541
|
page.off("dialog", dialogHandler);
|
|
538
542
|
page.off("console", consoleHandler);
|
|
539
543
|
currentPayloadInfo = null;
|
|
540
|
-
await
|
|
544
|
+
await ctx.options.onBeforeClose?.(page);
|
|
545
|
+
await context.close();
|
|
546
|
+
if (ownBrowser) {
|
|
547
|
+
await ownBrowser.close();
|
|
548
|
+
}
|
|
541
549
|
}
|
|
542
550
|
return {
|
|
543
551
|
findings: ctx.findings,
|
|
@@ -547,136 +555,264 @@ var BrowserRunner = class _BrowserRunner {
|
|
|
547
555
|
errors
|
|
548
556
|
};
|
|
549
557
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
case "browser.click":
|
|
572
|
-
if (injected) {
|
|
573
|
-
await Promise.all([
|
|
574
|
-
page.waitForNavigation({
|
|
575
|
-
waitUntil: "domcontentloaded",
|
|
576
|
-
timeout: 5e3
|
|
577
|
-
}).catch(() => {
|
|
578
|
-
}),
|
|
579
|
-
page.click(browserStep.selector, { timeout: 5e3 })
|
|
580
|
-
]);
|
|
581
|
-
} else {
|
|
582
|
-
await page.click(browserStep.selector, { timeout: 5e3 });
|
|
583
|
-
}
|
|
584
|
-
break;
|
|
585
|
-
case "browser.input": {
|
|
586
|
-
const value = step.id === targetStep.id ? payloadValue : browserStep.value;
|
|
587
|
-
await page.fill(browserStep.selector, value, { timeout: 5e3 });
|
|
588
|
-
if (step.id === targetStep.id) {
|
|
589
|
-
injected = true;
|
|
590
|
-
}
|
|
591
|
-
break;
|
|
558
|
+
};
|
|
559
|
+
function createDialogHandler(page, eventFindings, getPayloadInfo) {
|
|
560
|
+
return async (dialog) => {
|
|
561
|
+
const info = getPayloadInfo();
|
|
562
|
+
if (info) {
|
|
563
|
+
const message = dialog.message();
|
|
564
|
+
const dialogType = dialog.type();
|
|
565
|
+
if (dialogType !== "beforeunload") {
|
|
566
|
+
eventFindings.push({
|
|
567
|
+
type: "xss",
|
|
568
|
+
severity: "high",
|
|
569
|
+
title: `XSS Confirmed - ${dialogType}() triggered`,
|
|
570
|
+
description: `JavaScript ${dialogType}() dialog was triggered by payload injection. Message: "${message}"`,
|
|
571
|
+
stepId: info.stepId,
|
|
572
|
+
payload: info.payloadValue,
|
|
573
|
+
url: page.url(),
|
|
574
|
+
evidence: `Dialog type: ${dialogType}, Message: ${message}`,
|
|
575
|
+
metadata: {
|
|
576
|
+
dialogType,
|
|
577
|
+
dialogMessage: message,
|
|
578
|
+
detectionMethod: "dialog"
|
|
592
579
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await dialog.dismiss();
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function createConsoleHandler(eventFindings, getPayloadInfo) {
|
|
590
|
+
return async (msg) => {
|
|
591
|
+
const info = getPayloadInfo();
|
|
592
|
+
if (info && msg.type() === "log") {
|
|
593
|
+
const text = msg.text();
|
|
594
|
+
if (text.includes("vulcn") || text.includes(info.payloadValue)) {
|
|
595
|
+
eventFindings.push({
|
|
596
|
+
type: "xss",
|
|
597
|
+
severity: "high",
|
|
598
|
+
title: "XSS Confirmed - Console Output",
|
|
599
|
+
description: `JavaScript console.log was triggered by payload injection`,
|
|
600
|
+
stepId: info.stepId,
|
|
601
|
+
payload: info.payloadValue,
|
|
602
|
+
url: "",
|
|
603
|
+
evidence: `Console output: ${text}`,
|
|
604
|
+
metadata: {
|
|
605
|
+
consoleType: msg.type(),
|
|
606
|
+
detectionMethod: "console"
|
|
607
607
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
} catch
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
async function cyclePayload(page, session, targetStep, payloadValue, formPageUrl) {
|
|
614
|
+
try {
|
|
615
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: 5e3 });
|
|
616
|
+
const targetSelector = targetStep.selector;
|
|
617
|
+
const formPresent = await page.waitForSelector(targetSelector, { timeout: 3e3 }).then(() => true).catch(() => false);
|
|
618
|
+
if (!formPresent) {
|
|
619
|
+
await page.goto(formPageUrl, {
|
|
620
|
+
waitUntil: "domcontentloaded",
|
|
621
|
+
timeout: 5e3
|
|
622
|
+
});
|
|
623
|
+
const formPresentAfterNav = await page.waitForSelector(targetSelector, { timeout: 3e3 }).then(() => true).catch(() => false);
|
|
624
|
+
if (!formPresentAfterNav) {
|
|
625
|
+
return false;
|
|
624
626
|
}
|
|
625
627
|
}
|
|
628
|
+
await page.fill(targetSelector, payloadValue, { timeout: 5e3 });
|
|
629
|
+
await replayStepsAfter(page, session, targetStep);
|
|
626
630
|
await page.waitForTimeout(500);
|
|
631
|
+
return true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
627
634
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
635
|
+
}
|
|
636
|
+
async function replayWithPayload(page, session, targetStep, payloadValue, startUrl) {
|
|
637
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
638
|
+
let injected = false;
|
|
639
|
+
for (const step of session.steps) {
|
|
640
|
+
const browserStep = step;
|
|
641
|
+
try {
|
|
642
|
+
switch (browserStep.type) {
|
|
643
|
+
case "browser.navigate":
|
|
644
|
+
if (injected && browserStep.url.includes("sid=")) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
await page.goto(browserStep.url, { waitUntil: "domcontentloaded" });
|
|
648
|
+
break;
|
|
649
|
+
case "browser.click":
|
|
650
|
+
if (injected) {
|
|
651
|
+
await Promise.all([
|
|
652
|
+
page.waitForNavigation({
|
|
653
|
+
waitUntil: "domcontentloaded",
|
|
654
|
+
timeout: 5e3
|
|
655
|
+
}).catch(() => {
|
|
656
|
+
}),
|
|
657
|
+
page.click(browserStep.selector, { timeout: 5e3 })
|
|
658
|
+
]);
|
|
659
|
+
} else {
|
|
660
|
+
await page.click(browserStep.selector, { timeout: 5e3 });
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
case "browser.input": {
|
|
664
|
+
const value = step.id === targetStep.id ? payloadValue : browserStep.value;
|
|
665
|
+
await page.fill(browserStep.selector, value, { timeout: 5e3 });
|
|
666
|
+
if (step.id === targetStep.id) {
|
|
667
|
+
injected = true;
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
case "browser.keypress": {
|
|
672
|
+
const modifiers = browserStep.modifiers ?? [];
|
|
673
|
+
for (const mod of modifiers) {
|
|
674
|
+
await page.keyboard.down(
|
|
675
|
+
mod
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
await page.keyboard.press(browserStep.key);
|
|
679
|
+
for (const mod of modifiers.reverse()) {
|
|
680
|
+
await page.keyboard.up(mod);
|
|
681
|
+
}
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
case "browser.scroll":
|
|
685
|
+
if (browserStep.selector) {
|
|
686
|
+
await page.locator(browserStep.selector).evaluate((el, pos) => {
|
|
687
|
+
el.scrollTo(pos.x, pos.y);
|
|
688
|
+
}, browserStep.position);
|
|
689
|
+
} else {
|
|
690
|
+
await page.evaluate((pos) => {
|
|
691
|
+
window.scrollTo(pos.x, pos.y);
|
|
692
|
+
}, browserStep.position);
|
|
693
|
+
}
|
|
694
|
+
break;
|
|
695
|
+
case "browser.wait":
|
|
696
|
+
await page.waitForTimeout(browserStep.duration);
|
|
697
|
+
break;
|
|
645
698
|
}
|
|
699
|
+
} catch {
|
|
646
700
|
}
|
|
647
|
-
|
|
701
|
+
}
|
|
702
|
+
await page.waitForTimeout(500);
|
|
703
|
+
}
|
|
704
|
+
async function replayStepsAfter(page, session, targetStep) {
|
|
705
|
+
let pastTarget = false;
|
|
706
|
+
for (const step of session.steps) {
|
|
707
|
+
if (step.id === targetStep.id) {
|
|
708
|
+
pastTarget = true;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (!pastTarget) continue;
|
|
712
|
+
const browserStep = step;
|
|
713
|
+
try {
|
|
714
|
+
switch (browserStep.type) {
|
|
715
|
+
case "browser.navigate":
|
|
716
|
+
break;
|
|
717
|
+
case "browser.click":
|
|
718
|
+
await Promise.all([
|
|
719
|
+
page.waitForNavigation({
|
|
720
|
+
waitUntil: "domcontentloaded",
|
|
721
|
+
timeout: 5e3
|
|
722
|
+
}).catch(() => {
|
|
723
|
+
}),
|
|
724
|
+
page.click(browserStep.selector, { timeout: 5e3 })
|
|
725
|
+
]);
|
|
726
|
+
break;
|
|
727
|
+
case "browser.input":
|
|
728
|
+
await page.fill(browserStep.selector, browserStep.value, {
|
|
729
|
+
timeout: 5e3
|
|
730
|
+
});
|
|
731
|
+
break;
|
|
732
|
+
case "browser.keypress": {
|
|
733
|
+
const modifiers = browserStep.modifiers ?? [];
|
|
734
|
+
for (const mod of modifiers) {
|
|
735
|
+
await page.keyboard.down(
|
|
736
|
+
mod
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
await page.keyboard.press(browserStep.key);
|
|
740
|
+
for (const mod of modifiers.reverse()) {
|
|
741
|
+
await page.keyboard.up(mod);
|
|
742
|
+
}
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case "browser.scroll":
|
|
746
|
+
break;
|
|
747
|
+
// skip scrolls in fast path
|
|
748
|
+
case "browser.wait":
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
await page.waitForTimeout(500);
|
|
755
|
+
}
|
|
756
|
+
async function checkReflection(page, step, payloadSet, payloadValue) {
|
|
757
|
+
const content = await page.content();
|
|
758
|
+
for (const pattern of payloadSet.detectPatterns) {
|
|
759
|
+
if (pattern.test(content)) {
|
|
648
760
|
return {
|
|
649
761
|
type: payloadSet.category,
|
|
650
|
-
severity:
|
|
651
|
-
title:
|
|
652
|
-
description: `Payload was reflected in page
|
|
762
|
+
severity: getSeverity(payloadSet.category),
|
|
763
|
+
title: `${payloadSet.category.toUpperCase()} vulnerability detected`,
|
|
764
|
+
description: `Payload pattern was reflected in page content`,
|
|
653
765
|
stepId: step.id,
|
|
654
766
|
payload: payloadValue,
|
|
655
|
-
url: page.url()
|
|
767
|
+
url: page.url(),
|
|
768
|
+
evidence: content.match(pattern)?.[0]?.slice(0, 200)
|
|
656
769
|
};
|
|
657
770
|
}
|
|
658
|
-
return void 0;
|
|
659
771
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
772
|
+
if (content.includes(payloadValue)) {
|
|
773
|
+
return {
|
|
774
|
+
type: payloadSet.category,
|
|
775
|
+
severity: "medium",
|
|
776
|
+
title: `Potential ${payloadSet.category.toUpperCase()} - payload reflection`,
|
|
777
|
+
description: `Payload was reflected in page without encoding`,
|
|
778
|
+
stepId: step.id,
|
|
779
|
+
payload: payloadValue,
|
|
780
|
+
url: page.url()
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
return void 0;
|
|
784
|
+
}
|
|
785
|
+
function getSeverity(category) {
|
|
786
|
+
switch (category) {
|
|
787
|
+
case "sqli":
|
|
788
|
+
case "command-injection":
|
|
789
|
+
case "xxe":
|
|
790
|
+
return "critical";
|
|
791
|
+
case "xss":
|
|
792
|
+
case "ssrf":
|
|
793
|
+
case "path-traversal":
|
|
794
|
+
return "high";
|
|
795
|
+
case "open-redirect":
|
|
796
|
+
return "medium";
|
|
797
|
+
default:
|
|
798
|
+
return "medium";
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function interleavePayloads(payloads) {
|
|
802
|
+
const result = [];
|
|
803
|
+
const payloadsByCategory = payloads.map(
|
|
804
|
+
(ps) => ps.payloads.map((value) => ({ payloadSet: ps, value }))
|
|
805
|
+
);
|
|
806
|
+
const maxLen = Math.max(...payloadsByCategory.map((c) => c.length));
|
|
807
|
+
for (let i = 0; i < maxLen; i++) {
|
|
808
|
+
for (const category of payloadsByCategory) {
|
|
809
|
+
if (i < category.length) {
|
|
810
|
+
result.push(category[i]);
|
|
811
|
+
}
|
|
677
812
|
}
|
|
678
813
|
}
|
|
679
|
-
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
680
816
|
|
|
681
817
|
// src/crawler.ts
|
|
682
818
|
var INJECTABLE_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -713,7 +849,8 @@ async function crawlAndBuildSessions(config, options = {}) {
|
|
|
713
849
|
headless: config.headless ?? true
|
|
714
850
|
});
|
|
715
851
|
const context = await browser.newContext({
|
|
716
|
-
viewport: config.viewport ?? { width: 1280, height: 720 }
|
|
852
|
+
viewport: config.viewport ?? { width: 1280, height: 720 },
|
|
853
|
+
...options.storageState ? { storageState: JSON.parse(options.storageState) } : {}
|
|
717
854
|
});
|
|
718
855
|
try {
|
|
719
856
|
while (queue.length > 0 && visited.size < opts.maxPages) {
|
|
@@ -896,20 +1033,58 @@ async function discoverForms(page, pageUrl) {
|
|
|
896
1033
|
}
|
|
897
1034
|
return forms;
|
|
898
1035
|
}
|
|
1036
|
+
var REDIRECT_PARAMS = /* @__PURE__ */ new Set([
|
|
1037
|
+
"to",
|
|
1038
|
+
"url",
|
|
1039
|
+
"redirect",
|
|
1040
|
+
"redirect_uri",
|
|
1041
|
+
"redirect_url",
|
|
1042
|
+
"return",
|
|
1043
|
+
"return_url",
|
|
1044
|
+
"returnto",
|
|
1045
|
+
"next",
|
|
1046
|
+
"goto",
|
|
1047
|
+
"dest",
|
|
1048
|
+
"destination",
|
|
1049
|
+
"continue",
|
|
1050
|
+
"target",
|
|
1051
|
+
"rurl",
|
|
1052
|
+
"out",
|
|
1053
|
+
"link",
|
|
1054
|
+
"forward"
|
|
1055
|
+
]);
|
|
1056
|
+
function isExternalRedirectLink(link, origin) {
|
|
1057
|
+
try {
|
|
1058
|
+
const parsed = new URL(link);
|
|
1059
|
+
if (parsed.origin !== origin) return false;
|
|
1060
|
+
for (const [key, value] of parsed.searchParams) {
|
|
1061
|
+
if (REDIRECT_PARAMS.has(key.toLowerCase())) {
|
|
1062
|
+
try {
|
|
1063
|
+
const targetUrl = new URL(value);
|
|
1064
|
+
if (targetUrl.origin !== origin) return true;
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return false;
|
|
1070
|
+
} catch {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
899
1074
|
async function discoverLinks(page, origin, sameOrigin) {
|
|
900
1075
|
const links = await page.evaluate(() => {
|
|
901
1076
|
return Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"));
|
|
902
1077
|
});
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1078
|
+
return links.filter((link) => {
|
|
1079
|
+
try {
|
|
1080
|
+
const linkOrigin = new URL(link).origin;
|
|
1081
|
+
if (sameOrigin && linkOrigin !== origin) return false;
|
|
1082
|
+
if (isExternalRedirectLink(link, origin)) return false;
|
|
1083
|
+
return true;
|
|
1084
|
+
} catch {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
913
1088
|
}
|
|
914
1089
|
function buildSessions(forms) {
|
|
915
1090
|
const targetForms = forms.filter((f) => f.inputs.some((i) => i.injectable));
|
|
@@ -990,6 +1165,182 @@ function normalizeUrl(url) {
|
|
|
990
1165
|
}
|
|
991
1166
|
}
|
|
992
1167
|
|
|
1168
|
+
// src/auth.ts
|
|
1169
|
+
async function detectLoginForm(page) {
|
|
1170
|
+
return page.evaluate(() => {
|
|
1171
|
+
function findUsernameInput(container) {
|
|
1172
|
+
const selectors = [
|
|
1173
|
+
'input[autocomplete="username"]',
|
|
1174
|
+
'input[autocomplete="email"]',
|
|
1175
|
+
'input[type="email"]',
|
|
1176
|
+
'input[name*="user" i]',
|
|
1177
|
+
'input[name*="login" i]',
|
|
1178
|
+
'input[name*="email" i]',
|
|
1179
|
+
'input[id*="user" i]',
|
|
1180
|
+
'input[id*="login" i]',
|
|
1181
|
+
'input[id*="email" i]',
|
|
1182
|
+
'input[name*="name" i]',
|
|
1183
|
+
'input[type="text"]'
|
|
1184
|
+
];
|
|
1185
|
+
for (const sel of selectors) {
|
|
1186
|
+
const el = container.querySelector(sel);
|
|
1187
|
+
if (el && el.type !== "password" && el.type !== "hidden") {
|
|
1188
|
+
return el;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
function findSubmitButton(container) {
|
|
1194
|
+
const selectors = [
|
|
1195
|
+
'button[type="submit"]',
|
|
1196
|
+
'input[type="submit"]',
|
|
1197
|
+
"button:not([type])",
|
|
1198
|
+
'button[type="button"]'
|
|
1199
|
+
];
|
|
1200
|
+
for (const sel of selectors) {
|
|
1201
|
+
const el = container.querySelector(sel);
|
|
1202
|
+
if (el) return el;
|
|
1203
|
+
}
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
function getSelector(el) {
|
|
1207
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1208
|
+
if (el.getAttribute("name"))
|
|
1209
|
+
return `${el.tagName.toLowerCase()}[name="${CSS.escape(el.getAttribute("name"))}"]`;
|
|
1210
|
+
if (el.getAttribute("type") && el.tagName === "INPUT")
|
|
1211
|
+
return `input[type="${el.getAttribute("type")}"]`;
|
|
1212
|
+
const parent = el.parentElement;
|
|
1213
|
+
if (!parent) return el.tagName.toLowerCase();
|
|
1214
|
+
const siblings = Array.from(parent.children);
|
|
1215
|
+
const index = siblings.indexOf(el) + 1;
|
|
1216
|
+
return `${parent.tagName.toLowerCase()} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
|
|
1217
|
+
}
|
|
1218
|
+
const forms = document.querySelectorAll("form");
|
|
1219
|
+
for (const form of forms) {
|
|
1220
|
+
const passwordInput2 = form.querySelector(
|
|
1221
|
+
'input[type="password"]'
|
|
1222
|
+
);
|
|
1223
|
+
if (!passwordInput2) continue;
|
|
1224
|
+
const usernameInput = findUsernameInput(form);
|
|
1225
|
+
const submitButton = findSubmitButton(form);
|
|
1226
|
+
return {
|
|
1227
|
+
usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
|
|
1228
|
+
passwordSelector: getSelector(passwordInput2),
|
|
1229
|
+
submitSelector: submitButton ? getSelector(submitButton) : null,
|
|
1230
|
+
autoDetected: true
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const passwordInput = document.querySelector(
|
|
1234
|
+
'input[type="password"]'
|
|
1235
|
+
);
|
|
1236
|
+
if (passwordInput) {
|
|
1237
|
+
const usernameInput = findUsernameInput(document.body);
|
|
1238
|
+
const submitButton = findSubmitButton(document.body);
|
|
1239
|
+
return {
|
|
1240
|
+
usernameSelector: usernameInput ? getSelector(usernameInput) : 'input[type="text"]',
|
|
1241
|
+
passwordSelector: getSelector(passwordInput),
|
|
1242
|
+
submitSelector: submitButton ? getSelector(submitButton) : null,
|
|
1243
|
+
autoDetected: true
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return null;
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
async function performLogin(page, context, credentials, options) {
|
|
1250
|
+
const loginUrl = credentials.loginUrl ?? options.targetUrl;
|
|
1251
|
+
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 15e3 });
|
|
1252
|
+
let usernameSelector;
|
|
1253
|
+
let passwordSelector;
|
|
1254
|
+
let submitSelector;
|
|
1255
|
+
if (credentials.userSelector && credentials.passSelector) {
|
|
1256
|
+
usernameSelector = credentials.userSelector;
|
|
1257
|
+
passwordSelector = credentials.passSelector;
|
|
1258
|
+
submitSelector = null;
|
|
1259
|
+
} else {
|
|
1260
|
+
const form = await detectLoginForm(page);
|
|
1261
|
+
if (!form) {
|
|
1262
|
+
return {
|
|
1263
|
+
success: false,
|
|
1264
|
+
message: `No login form detected on ${loginUrl}. Use --user-field and --pass-field to specify selectors.`
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
usernameSelector = form.usernameSelector;
|
|
1268
|
+
passwordSelector = form.passwordSelector;
|
|
1269
|
+
submitSelector = form.submitSelector;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
await page.fill(usernameSelector, credentials.username, { timeout: 5e3 });
|
|
1273
|
+
} catch {
|
|
1274
|
+
return {
|
|
1275
|
+
success: false,
|
|
1276
|
+
message: `Could not find username field: ${usernameSelector}`
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
await page.fill(passwordSelector, credentials.password, { timeout: 5e3 });
|
|
1281
|
+
} catch {
|
|
1282
|
+
return {
|
|
1283
|
+
success: false,
|
|
1284
|
+
message: `Could not find password field: ${passwordSelector}`
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
if (submitSelector) {
|
|
1289
|
+
await Promise.all([
|
|
1290
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
|
|
1291
|
+
}),
|
|
1292
|
+
page.click(submitSelector, { timeout: 5e3 })
|
|
1293
|
+
]);
|
|
1294
|
+
} else {
|
|
1295
|
+
await Promise.all([
|
|
1296
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 1e4 }).catch(() => {
|
|
1297
|
+
}),
|
|
1298
|
+
page.press(passwordSelector, "Enter")
|
|
1299
|
+
]);
|
|
1300
|
+
}
|
|
1301
|
+
} catch {
|
|
1302
|
+
return {
|
|
1303
|
+
success: false,
|
|
1304
|
+
message: "Failed to submit login form"
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
await page.waitForTimeout(1e3);
|
|
1308
|
+
const bodyText = await page.textContent("body").catch(() => "");
|
|
1309
|
+
if (options.loggedOutIndicator && bodyText?.includes(options.loggedOutIndicator)) {
|
|
1310
|
+
return {
|
|
1311
|
+
success: false,
|
|
1312
|
+
message: `Login failed \u2014 "${options.loggedOutIndicator}" still visible on page`
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
if (options.loggedInIndicator && !bodyText?.includes(options.loggedInIndicator)) {
|
|
1316
|
+
return {
|
|
1317
|
+
success: false,
|
|
1318
|
+
message: `Login uncertain \u2014 "${options.loggedInIndicator}" not found on page`
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
const storageState = JSON.stringify(await context.storageState());
|
|
1322
|
+
return {
|
|
1323
|
+
success: true,
|
|
1324
|
+
message: "Login successful",
|
|
1325
|
+
storageState
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
async function checkSessionAlive(page, config) {
|
|
1329
|
+
try {
|
|
1330
|
+
const bodyText = await page.textContent("body");
|
|
1331
|
+
if (!bodyText) return true;
|
|
1332
|
+
if (config.loggedOutIndicator && bodyText.includes(config.loggedOutIndicator)) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
if (config.loggedInIndicator && !bodyText.includes(config.loggedInIndicator)) {
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
return true;
|
|
1339
|
+
} catch {
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
993
1344
|
// src/index.ts
|
|
994
1345
|
var configSchema = import_zod.z.object({
|
|
995
1346
|
/** Starting URL for recording */
|
|
@@ -1096,9 +1447,12 @@ var index_default = browserDriver;
|
|
|
1096
1447
|
BrowserRunner,
|
|
1097
1448
|
BrowserStepSchema,
|
|
1098
1449
|
checkBrowsers,
|
|
1450
|
+
checkSessionAlive,
|
|
1099
1451
|
configSchema,
|
|
1100
1452
|
crawlAndBuildSessions,
|
|
1453
|
+
detectLoginForm,
|
|
1101
1454
|
installBrowsers,
|
|
1102
|
-
launchBrowser
|
|
1455
|
+
launchBrowser,
|
|
1456
|
+
performLogin
|
|
1103
1457
|
});
|
|
1104
1458
|
//# sourceMappingURL=index.cjs.map
|