@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.js
CHANGED
|
@@ -368,9 +368,12 @@ var BrowserRecorder = class _BrowserRecorder {
|
|
|
368
368
|
};
|
|
369
369
|
|
|
370
370
|
// src/runner.ts
|
|
371
|
-
var BrowserRunner = class
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
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 =
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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:
|
|
618
|
-
title:
|
|
619
|
-
description: `Payload was reflected in page
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|