@vulcn/engine 0.7.0 → 0.8.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/CHANGELOG.md +29 -0
- package/dist/index.cjs +370 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +246 -1
- package/dist/index.d.ts +246 -1
- package/dist/index.js +361 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -124,6 +124,10 @@ interface PluginHooks {
|
|
|
124
124
|
onRecordStep?: (step: Step, ctx: RecordContext) => Promise<Step>;
|
|
125
125
|
/** Called when recording ends, can transform session */
|
|
126
126
|
onRecordEnd?: (session: Session, ctx: RecordContext) => Promise<Session>;
|
|
127
|
+
/** Called once when a scan starts (before any session is executed) */
|
|
128
|
+
onScanStart?: (ctx: ScanContext) => Promise<void>;
|
|
129
|
+
/** Called once when a scan ends (after all sessions have executed) */
|
|
130
|
+
onScanEnd?: (result: RunResult, ctx: ScanContext) => Promise<RunResult>;
|
|
127
131
|
/** Called when run starts */
|
|
128
132
|
onRunStart?: (ctx: RunContext$1) => Promise<void>;
|
|
129
133
|
/** Called before each payload is injected, can transform payload */
|
|
@@ -206,6 +210,17 @@ interface RunContext$1 extends PluginContext {
|
|
|
206
210
|
/** Whether running headless */
|
|
207
211
|
headless: boolean;
|
|
208
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Context for scan-level hooks (wraps all sessions)
|
|
215
|
+
*/
|
|
216
|
+
interface ScanContext extends PluginContext {
|
|
217
|
+
/** All sessions in this scan */
|
|
218
|
+
sessions: Session[];
|
|
219
|
+
/** Whether running headless */
|
|
220
|
+
headless: boolean;
|
|
221
|
+
/** Total sessions count */
|
|
222
|
+
sessionCount: number;
|
|
223
|
+
}
|
|
209
224
|
/**
|
|
210
225
|
* Context for detection hooks
|
|
211
226
|
*/
|
|
@@ -450,6 +465,8 @@ interface CrawlOptions {
|
|
|
450
465
|
pageTimeout?: number;
|
|
451
466
|
/** Only crawl pages under the same origin (default: true) */
|
|
452
467
|
sameOrigin?: boolean;
|
|
468
|
+
/** Playwright storage state JSON for authenticated crawling */
|
|
469
|
+
storageState?: string;
|
|
453
470
|
/** Callback when a page is crawled */
|
|
454
471
|
onPageCrawled?: (url: string, formsFound: number) => void;
|
|
455
472
|
}
|
|
@@ -655,6 +672,23 @@ declare class DriverManager {
|
|
|
655
672
|
* via the onPageReady callback, ensuring plugins get a real page object.
|
|
656
673
|
*/
|
|
657
674
|
execute(session: Session, pluginManager: PluginManager, options?: RunOptions): Promise<RunResult>;
|
|
675
|
+
/**
|
|
676
|
+
* Execute multiple sessions with a shared browser (scan-level orchestration).
|
|
677
|
+
*
|
|
678
|
+
* This is the preferred entry point for running a full scan. It:
|
|
679
|
+
* 1. Launches ONE browser for the entire scan
|
|
680
|
+
* 2. Passes the browser to each session's runner via options.browser
|
|
681
|
+
* 3. Each session creates its own context (lightweight, isolated cookies)
|
|
682
|
+
* 4. Aggregates results across all sessions
|
|
683
|
+
* 5. Closes the browser once at the end
|
|
684
|
+
*
|
|
685
|
+
* This is 5-10x faster than calling execute() per session because
|
|
686
|
+
* launching a browser takes 2-3 seconds.
|
|
687
|
+
*/
|
|
688
|
+
executeScan(sessions: Session[], pluginManager: PluginManager, options?: RunOptions): Promise<{
|
|
689
|
+
results: RunResult[];
|
|
690
|
+
aggregate: RunResult;
|
|
691
|
+
}>;
|
|
658
692
|
/**
|
|
659
693
|
* Validate driver structure
|
|
660
694
|
*/
|
|
@@ -669,4 +703,215 @@ declare class DriverManager {
|
|
|
669
703
|
*/
|
|
670
704
|
declare const driverManager: DriverManager;
|
|
671
705
|
|
|
672
|
-
|
|
706
|
+
/**
|
|
707
|
+
* Vulcn Auth Module
|
|
708
|
+
*
|
|
709
|
+
* Handles credential encryption/decryption and auth state management.
|
|
710
|
+
*
|
|
711
|
+
* Security:
|
|
712
|
+
* - AES-256-GCM encryption for credentials at rest
|
|
713
|
+
* - PBKDF2 key derivation from passphrase
|
|
714
|
+
* - Reads passphrase from VULCN_KEY env var (CI/CD) or interactive prompt
|
|
715
|
+
* - Auth state (cookies, localStorage) encrypted separately
|
|
716
|
+
*/
|
|
717
|
+
/** Form-based login credentials */
|
|
718
|
+
interface FormCredentials {
|
|
719
|
+
type: "form";
|
|
720
|
+
username: string;
|
|
721
|
+
password: string;
|
|
722
|
+
/** Custom login URL (if different from target) */
|
|
723
|
+
loginUrl?: string;
|
|
724
|
+
/** Custom CSS selector for username field */
|
|
725
|
+
userSelector?: string;
|
|
726
|
+
/** Custom CSS selector for password field */
|
|
727
|
+
passSelector?: string;
|
|
728
|
+
}
|
|
729
|
+
/** Header-based authentication (API keys, Bearer tokens) */
|
|
730
|
+
interface HeaderCredentials {
|
|
731
|
+
type: "header";
|
|
732
|
+
headers: Record<string, string>;
|
|
733
|
+
}
|
|
734
|
+
/** All credential types */
|
|
735
|
+
type Credentials = FormCredentials | HeaderCredentials;
|
|
736
|
+
/** Auth configuration for a scan */
|
|
737
|
+
interface AuthConfig {
|
|
738
|
+
/** Auth strategy */
|
|
739
|
+
strategy: "storage-state" | "header";
|
|
740
|
+
/** Login page URL */
|
|
741
|
+
loginUrl?: string;
|
|
742
|
+
/** Text that appears when logged in (e.g., "Logout") */
|
|
743
|
+
loggedInIndicator?: string;
|
|
744
|
+
/** Text that appears when logged out (e.g., "Sign In") */
|
|
745
|
+
loggedOutIndicator?: string;
|
|
746
|
+
/** Session expiry detection rules */
|
|
747
|
+
sessionExpiry?: {
|
|
748
|
+
/** HTTP status codes that indicate session expired */
|
|
749
|
+
statusCodes?: number[];
|
|
750
|
+
/** URL pattern that indicates redirect to login */
|
|
751
|
+
redirectPattern?: string;
|
|
752
|
+
/** Page content that indicates session expired */
|
|
753
|
+
pageContent?: string;
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Encrypt data with AES-256-GCM.
|
|
758
|
+
*
|
|
759
|
+
* @param data - Plaintext data to encrypt
|
|
760
|
+
* @param passphrase - Passphrase for key derivation
|
|
761
|
+
* @returns JSON string of EncryptedData
|
|
762
|
+
*/
|
|
763
|
+
declare function encrypt(data: string, passphrase: string): string;
|
|
764
|
+
/**
|
|
765
|
+
* Decrypt data encrypted with encrypt().
|
|
766
|
+
*
|
|
767
|
+
* @param encrypted - JSON string from encrypt()
|
|
768
|
+
* @param passphrase - Passphrase used during encryption
|
|
769
|
+
* @returns Decrypted plaintext
|
|
770
|
+
* @throws Error if passphrase is wrong or data is tampered
|
|
771
|
+
*/
|
|
772
|
+
declare function decrypt(encrypted: string, passphrase: string): string;
|
|
773
|
+
/**
|
|
774
|
+
* Encrypt credentials to a storable string.
|
|
775
|
+
*/
|
|
776
|
+
declare function encryptCredentials(credentials: Credentials, passphrase: string): string;
|
|
777
|
+
/**
|
|
778
|
+
* Decrypt credentials from a stored string.
|
|
779
|
+
*/
|
|
780
|
+
declare function decryptCredentials(encrypted: string, passphrase: string): Credentials;
|
|
781
|
+
/**
|
|
782
|
+
* Encrypt browser storage state (cookies, localStorage, etc.).
|
|
783
|
+
* The state is the JSON output from Playwright's context.storageState().
|
|
784
|
+
*/
|
|
785
|
+
declare function encryptStorageState(storageState: string, passphrase: string): string;
|
|
786
|
+
/**
|
|
787
|
+
* Decrypt browser storage state.
|
|
788
|
+
*/
|
|
789
|
+
declare function decryptStorageState(encrypted: string, passphrase: string): string;
|
|
790
|
+
/**
|
|
791
|
+
* Get passphrase from environment or throw.
|
|
792
|
+
*
|
|
793
|
+
* In CI/CD, set VULCN_KEY env var.
|
|
794
|
+
* In interactive mode, the CLI should prompt and pass the value here.
|
|
795
|
+
*/
|
|
796
|
+
declare function getPassphrase(interactive?: string): string;
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Vulcn Session Format v2
|
|
800
|
+
*
|
|
801
|
+
* Directory-based session format: `.vulcn/` or `<name>.vulcn/`
|
|
802
|
+
*
|
|
803
|
+
* Structure:
|
|
804
|
+
* manifest.yml - scan config, session list, auth config
|
|
805
|
+
* auth/config.yml - login strategy, indicators
|
|
806
|
+
* auth/state.enc - encrypted storageState (cookies/localStorage)
|
|
807
|
+
* sessions/*.yml - individual session files (one per form)
|
|
808
|
+
* requests/*.json - captured HTTP metadata (for Tier 1 fast scan)
|
|
809
|
+
*/
|
|
810
|
+
|
|
811
|
+
/** Manifest file schema (manifest.yml) */
|
|
812
|
+
interface ScanManifest {
|
|
813
|
+
/** Format version */
|
|
814
|
+
version: "2";
|
|
815
|
+
/** Human-readable scan name */
|
|
816
|
+
name: string;
|
|
817
|
+
/** Target URL */
|
|
818
|
+
target: string;
|
|
819
|
+
/** When the scan was recorded */
|
|
820
|
+
recordedAt: string;
|
|
821
|
+
/** Driver name */
|
|
822
|
+
driver: string;
|
|
823
|
+
/** Driver configuration */
|
|
824
|
+
driverConfig: Record<string, unknown>;
|
|
825
|
+
/** Auth configuration (optional) */
|
|
826
|
+
auth?: {
|
|
827
|
+
strategy: string;
|
|
828
|
+
configFile?: string;
|
|
829
|
+
stateFile?: string;
|
|
830
|
+
loggedInIndicator?: string;
|
|
831
|
+
loggedOutIndicator?: string;
|
|
832
|
+
reAuthOn?: Array<Record<string, unknown>>;
|
|
833
|
+
};
|
|
834
|
+
/** Session file references */
|
|
835
|
+
sessions: SessionRef[];
|
|
836
|
+
/** Scan configuration */
|
|
837
|
+
scan?: {
|
|
838
|
+
tier?: "auto" | "http-only" | "browser-only";
|
|
839
|
+
parallel?: number;
|
|
840
|
+
timeout?: number;
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/** Reference to a session file within the manifest */
|
|
844
|
+
interface SessionRef {
|
|
845
|
+
/** Relative path to session file */
|
|
846
|
+
file: string;
|
|
847
|
+
/** Whether this session has injectable inputs */
|
|
848
|
+
injectable?: boolean;
|
|
849
|
+
}
|
|
850
|
+
/** HTTP request metadata for Tier 1 fast scanning */
|
|
851
|
+
interface CapturedRequest {
|
|
852
|
+
/** Request method */
|
|
853
|
+
method: string;
|
|
854
|
+
/** Full URL */
|
|
855
|
+
url: string;
|
|
856
|
+
/** Request headers */
|
|
857
|
+
headers: Record<string, string>;
|
|
858
|
+
/** Form data (for POST) */
|
|
859
|
+
body?: string;
|
|
860
|
+
/** Content type */
|
|
861
|
+
contentType?: string;
|
|
862
|
+
/** Which form field is injectable */
|
|
863
|
+
injectableField?: string;
|
|
864
|
+
/** Session name this request belongs to */
|
|
865
|
+
sessionName: string;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Load a v2 session directory into Session[] ready for execution.
|
|
869
|
+
*
|
|
870
|
+
* @param dirPath - Path to the .vulcn/ directory
|
|
871
|
+
* @returns Array of sessions with manifest metadata attached
|
|
872
|
+
*/
|
|
873
|
+
declare function loadSessionDir(dirPath: string): Promise<{
|
|
874
|
+
manifest: ScanManifest;
|
|
875
|
+
sessions: Session[];
|
|
876
|
+
authConfig?: AuthConfig;
|
|
877
|
+
}>;
|
|
878
|
+
/**
|
|
879
|
+
* Check if a path is a v2 session directory.
|
|
880
|
+
*/
|
|
881
|
+
declare function isSessionDir(path: string): boolean;
|
|
882
|
+
/**
|
|
883
|
+
* Check if a path looks like a v2 session directory (by extension).
|
|
884
|
+
*/
|
|
885
|
+
declare function looksLikeSessionDir(path: string): boolean;
|
|
886
|
+
/**
|
|
887
|
+
* Save sessions to a v2 session directory.
|
|
888
|
+
*
|
|
889
|
+
* Creates the directory structure:
|
|
890
|
+
* <dirPath>/
|
|
891
|
+
* ├── manifest.yml
|
|
892
|
+
* ├── sessions/
|
|
893
|
+
* │ ├── <session-name>.yml
|
|
894
|
+
* │ └── ...
|
|
895
|
+
* └── requests/ (if HTTP metadata provided)
|
|
896
|
+
* └── ...
|
|
897
|
+
*/
|
|
898
|
+
declare function saveSessionDir(dirPath: string, options: {
|
|
899
|
+
name: string;
|
|
900
|
+
target: string;
|
|
901
|
+
driver: string;
|
|
902
|
+
driverConfig: Record<string, unknown>;
|
|
903
|
+
sessions: Session[];
|
|
904
|
+
authConfig?: AuthConfig;
|
|
905
|
+
encryptedState?: string;
|
|
906
|
+
requests?: CapturedRequest[];
|
|
907
|
+
}): Promise<void>;
|
|
908
|
+
/**
|
|
909
|
+
* Read encrypted auth state from a session directory.
|
|
910
|
+
*/
|
|
911
|
+
declare function readAuthState(dirPath: string): Promise<string | null>;
|
|
912
|
+
/**
|
|
913
|
+
* Read captured HTTP requests from a session directory.
|
|
914
|
+
*/
|
|
915
|
+
declare function readCapturedRequests(dirPath: string): Promise<CapturedRequest[]>;
|
|
916
|
+
|
|
917
|
+
export { type AuthConfig, type CapturedRequest, type CrawlOptions, type Credentials, type CustomPayload, type CustomPayloadFile, DRIVER_API_VERSION, type DetectContext, type DriverLogger, DriverManager, type DriverSource, type EngineInfo, type Finding, type FormCredentials, type HeaderCredentials, type LoadedDriver, type LoadedPlugin as LoadedPluginInfo, PLUGIN_API_VERSION, type PayloadCategory, type PayloadSource, type PluginConfig, type PluginContext, type PluginHooks, type PluginLogger, PluginManager, type RunContext$1 as PluginRunContext, type PluginSource, type RecordContext, type RecordOptions, type RecorderDriver, type RecordingHandle, type RunContext, type RunOptions, type RunResult, type RunnerDriver, type RuntimePayload, type ScanContext, type ScanManifest, type Session, type SessionRef, type Step, type VulcnConfig, type VulcnDriver, type VulcnPlugin, decrypt, decryptCredentials, decryptStorageState, driverManager, encrypt, encryptCredentials, encryptStorageState, getPassphrase, isSessionDir, loadSessionDir, looksLikeSessionDir, pluginManager, readAuthState, readCapturedRequests, saveSessionDir };
|
package/dist/index.js
CHANGED
|
@@ -243,6 +243,109 @@ var DriverManager = class {
|
|
|
243
243
|
}
|
|
244
244
|
return result;
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Execute multiple sessions with a shared browser (scan-level orchestration).
|
|
248
|
+
*
|
|
249
|
+
* This is the preferred entry point for running a full scan. It:
|
|
250
|
+
* 1. Launches ONE browser for the entire scan
|
|
251
|
+
* 2. Passes the browser to each session's runner via options.browser
|
|
252
|
+
* 3. Each session creates its own context (lightweight, isolated cookies)
|
|
253
|
+
* 4. Aggregates results across all sessions
|
|
254
|
+
* 5. Closes the browser once at the end
|
|
255
|
+
*
|
|
256
|
+
* This is 5-10x faster than calling execute() per session because
|
|
257
|
+
* launching a browser takes 2-3 seconds.
|
|
258
|
+
*/
|
|
259
|
+
async executeScan(sessions, pluginManager2, options = {}) {
|
|
260
|
+
if (sessions.length === 0) {
|
|
261
|
+
const empty = {
|
|
262
|
+
findings: [],
|
|
263
|
+
stepsExecuted: 0,
|
|
264
|
+
payloadsTested: 0,
|
|
265
|
+
duration: 0,
|
|
266
|
+
errors: ["No sessions to execute"]
|
|
267
|
+
};
|
|
268
|
+
return { results: [], aggregate: empty };
|
|
269
|
+
}
|
|
270
|
+
const startTime = Date.now();
|
|
271
|
+
const results = [];
|
|
272
|
+
const allFindings = [];
|
|
273
|
+
let totalSteps = 0;
|
|
274
|
+
let totalPayloads = 0;
|
|
275
|
+
const allErrors = [];
|
|
276
|
+
const firstDriver = this.getForSession(sessions[0]);
|
|
277
|
+
let sharedBrowser = null;
|
|
278
|
+
if (firstDriver.name === "browser") {
|
|
279
|
+
try {
|
|
280
|
+
const driverPkg = "@vulcn/driver-browser";
|
|
281
|
+
const { launchBrowser } = await import(
|
|
282
|
+
/* @vite-ignore */
|
|
283
|
+
driverPkg
|
|
284
|
+
);
|
|
285
|
+
const browserType = sessions[0].driverConfig.browser ?? "chromium";
|
|
286
|
+
const headless = options.headless ?? true;
|
|
287
|
+
const result = await launchBrowser({
|
|
288
|
+
browser: browserType,
|
|
289
|
+
headless
|
|
290
|
+
});
|
|
291
|
+
sharedBrowser = result.browser;
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await pluginManager2.callHook("onScanStart", async (hook, ctx) => {
|
|
297
|
+
const scanCtx = {
|
|
298
|
+
...ctx,
|
|
299
|
+
sessions,
|
|
300
|
+
headless: options.headless ?? true,
|
|
301
|
+
sessionCount: sessions.length
|
|
302
|
+
};
|
|
303
|
+
await hook(scanCtx);
|
|
304
|
+
});
|
|
305
|
+
for (const session of sessions) {
|
|
306
|
+
const sessionOptions = {
|
|
307
|
+
...options,
|
|
308
|
+
...sharedBrowser ? { browser: sharedBrowser } : {}
|
|
309
|
+
};
|
|
310
|
+
const result = await this.execute(
|
|
311
|
+
session,
|
|
312
|
+
pluginManager2,
|
|
313
|
+
sessionOptions
|
|
314
|
+
);
|
|
315
|
+
results.push(result);
|
|
316
|
+
allFindings.push(...result.findings);
|
|
317
|
+
totalSteps += result.stepsExecuted;
|
|
318
|
+
totalPayloads += result.payloadsTested;
|
|
319
|
+
allErrors.push(...result.errors);
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
if (sharedBrowser && typeof sharedBrowser.close === "function") {
|
|
323
|
+
await sharedBrowser.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const aggregate = {
|
|
327
|
+
findings: allFindings,
|
|
328
|
+
stepsExecuted: totalSteps,
|
|
329
|
+
payloadsTested: totalPayloads,
|
|
330
|
+
duration: Date.now() - startTime,
|
|
331
|
+
errors: allErrors
|
|
332
|
+
};
|
|
333
|
+
let finalAggregate = aggregate;
|
|
334
|
+
finalAggregate = await pluginManager2.callHookPipe(
|
|
335
|
+
"onScanEnd",
|
|
336
|
+
finalAggregate,
|
|
337
|
+
async (hook, value, ctx) => {
|
|
338
|
+
const scanCtx = {
|
|
339
|
+
...ctx,
|
|
340
|
+
sessions,
|
|
341
|
+
headless: options.headless ?? true,
|
|
342
|
+
sessionCount: sessions.length
|
|
343
|
+
};
|
|
344
|
+
return await hook(value, scanCtx);
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
return { results, aggregate: finalAggregate };
|
|
348
|
+
}
|
|
246
349
|
/**
|
|
247
350
|
* Validate driver structure
|
|
248
351
|
*/
|
|
@@ -616,12 +719,269 @@ var PluginManager = class {
|
|
|
616
719
|
}
|
|
617
720
|
};
|
|
618
721
|
var pluginManager = new PluginManager();
|
|
722
|
+
|
|
723
|
+
// src/auth.ts
|
|
724
|
+
import {
|
|
725
|
+
randomBytes,
|
|
726
|
+
createCipheriv,
|
|
727
|
+
createDecipheriv,
|
|
728
|
+
pbkdf2Sync
|
|
729
|
+
} from "crypto";
|
|
730
|
+
var ALGORITHM = "aes-256-gcm";
|
|
731
|
+
var KEY_LENGTH = 32;
|
|
732
|
+
var IV_LENGTH = 16;
|
|
733
|
+
var SALT_LENGTH = 32;
|
|
734
|
+
var PBKDF2_ITERATIONS = 1e5;
|
|
735
|
+
var PBKDF2_DIGEST = "sha512";
|
|
736
|
+
function deriveKey(passphrase, salt) {
|
|
737
|
+
return pbkdf2Sync(
|
|
738
|
+
passphrase,
|
|
739
|
+
salt,
|
|
740
|
+
PBKDF2_ITERATIONS,
|
|
741
|
+
KEY_LENGTH,
|
|
742
|
+
PBKDF2_DIGEST
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
function encrypt(data, passphrase) {
|
|
746
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
747
|
+
const iv = randomBytes(IV_LENGTH);
|
|
748
|
+
const key = deriveKey(passphrase, salt);
|
|
749
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
750
|
+
let encrypted = cipher.update(data, "utf8", "hex");
|
|
751
|
+
encrypted += cipher.final("hex");
|
|
752
|
+
const tag = cipher.getAuthTag();
|
|
753
|
+
const payload = {
|
|
754
|
+
version: 1,
|
|
755
|
+
salt: salt.toString("hex"),
|
|
756
|
+
iv: iv.toString("hex"),
|
|
757
|
+
tag: tag.toString("hex"),
|
|
758
|
+
data: encrypted,
|
|
759
|
+
iterations: PBKDF2_ITERATIONS
|
|
760
|
+
};
|
|
761
|
+
return JSON.stringify(payload);
|
|
762
|
+
}
|
|
763
|
+
function decrypt(encrypted, passphrase) {
|
|
764
|
+
const payload = JSON.parse(encrypted);
|
|
765
|
+
if (payload.version !== 1) {
|
|
766
|
+
throw new Error(`Unsupported encryption version: ${payload.version}`);
|
|
767
|
+
}
|
|
768
|
+
const salt = Buffer.from(payload.salt, "hex");
|
|
769
|
+
const iv = Buffer.from(payload.iv, "hex");
|
|
770
|
+
const tag = Buffer.from(payload.tag, "hex");
|
|
771
|
+
const key = deriveKey(passphrase, salt);
|
|
772
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
773
|
+
decipher.setAuthTag(tag);
|
|
774
|
+
let decrypted = decipher.update(payload.data, "hex", "utf8");
|
|
775
|
+
decrypted += decipher.final("utf8");
|
|
776
|
+
return decrypted;
|
|
777
|
+
}
|
|
778
|
+
function encryptCredentials(credentials, passphrase) {
|
|
779
|
+
return encrypt(JSON.stringify(credentials), passphrase);
|
|
780
|
+
}
|
|
781
|
+
function decryptCredentials(encrypted, passphrase) {
|
|
782
|
+
const json = decrypt(encrypted, passphrase);
|
|
783
|
+
return JSON.parse(json);
|
|
784
|
+
}
|
|
785
|
+
function encryptStorageState(storageState, passphrase) {
|
|
786
|
+
return encrypt(storageState, passphrase);
|
|
787
|
+
}
|
|
788
|
+
function decryptStorageState(encrypted, passphrase) {
|
|
789
|
+
return decrypt(encrypted, passphrase);
|
|
790
|
+
}
|
|
791
|
+
function getPassphrase(interactive) {
|
|
792
|
+
if (interactive) return interactive;
|
|
793
|
+
const envKey = process.env.VULCN_KEY;
|
|
794
|
+
if (envKey) return envKey;
|
|
795
|
+
throw new Error(
|
|
796
|
+
"No passphrase provided. Set VULCN_KEY environment variable or pass --passphrase."
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/session.ts
|
|
801
|
+
import { readFile as readFile2, writeFile, mkdir, readdir } from "fs/promises";
|
|
802
|
+
import { existsSync as existsSync2 } from "fs";
|
|
803
|
+
import { join, basename } from "path";
|
|
804
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
805
|
+
async function loadSessionDir(dirPath) {
|
|
806
|
+
const manifestPath = join(dirPath, "manifest.yml");
|
|
807
|
+
if (!existsSync2(manifestPath)) {
|
|
808
|
+
throw new Error(
|
|
809
|
+
`No manifest.yml found in ${dirPath}. Is this a v2 session directory?`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
const manifestYaml = await readFile2(manifestPath, "utf-8");
|
|
813
|
+
const manifest = parse2(manifestYaml);
|
|
814
|
+
if (manifest.version !== "2") {
|
|
815
|
+
throw new Error(
|
|
816
|
+
`Unsupported session format version: ${manifest.version}. Expected "2".`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
let authConfig;
|
|
820
|
+
if (manifest.auth?.configFile) {
|
|
821
|
+
const authPath = join(dirPath, manifest.auth.configFile);
|
|
822
|
+
if (existsSync2(authPath)) {
|
|
823
|
+
const authYaml = await readFile2(authPath, "utf-8");
|
|
824
|
+
authConfig = parse2(authYaml);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const sessions = [];
|
|
828
|
+
for (const ref of manifest.sessions) {
|
|
829
|
+
if (ref.injectable === false) continue;
|
|
830
|
+
const sessionPath = join(dirPath, ref.file);
|
|
831
|
+
if (!existsSync2(sessionPath)) {
|
|
832
|
+
console.warn(`Session file not found: ${sessionPath}, skipping`);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const sessionYaml = await readFile2(sessionPath, "utf-8");
|
|
836
|
+
const sessionData = parse2(sessionYaml);
|
|
837
|
+
const session = {
|
|
838
|
+
name: sessionData.name ?? basename(ref.file, ".yml"),
|
|
839
|
+
driver: manifest.driver,
|
|
840
|
+
driverConfig: {
|
|
841
|
+
...manifest.driverConfig,
|
|
842
|
+
startUrl: resolveUrl(
|
|
843
|
+
manifest.target,
|
|
844
|
+
sessionData.page
|
|
845
|
+
)
|
|
846
|
+
},
|
|
847
|
+
steps: sessionData.steps ?? [],
|
|
848
|
+
metadata: {
|
|
849
|
+
recordedAt: manifest.recordedAt,
|
|
850
|
+
version: "2",
|
|
851
|
+
manifestDir: dirPath
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
sessions.push(session);
|
|
855
|
+
}
|
|
856
|
+
return { manifest, sessions, authConfig };
|
|
857
|
+
}
|
|
858
|
+
function isSessionDir(path) {
|
|
859
|
+
return existsSync2(join(path, "manifest.yml"));
|
|
860
|
+
}
|
|
861
|
+
function looksLikeSessionDir(path) {
|
|
862
|
+
return path.endsWith(".vulcn") || path.endsWith(".vulcn/");
|
|
863
|
+
}
|
|
864
|
+
async function saveSessionDir(dirPath, options) {
|
|
865
|
+
await mkdir(join(dirPath, "sessions"), { recursive: true });
|
|
866
|
+
const sessionRefs = [];
|
|
867
|
+
for (const session of options.sessions) {
|
|
868
|
+
const safeName = slugify(session.name);
|
|
869
|
+
const fileName = `sessions/${safeName}.yml`;
|
|
870
|
+
const sessionPath = join(dirPath, fileName);
|
|
871
|
+
const startUrl = session.driverConfig.startUrl;
|
|
872
|
+
const page = startUrl ? startUrl.replace(options.target, "").replace(/^\//, "/") : void 0;
|
|
873
|
+
const sessionData = {
|
|
874
|
+
name: session.name,
|
|
875
|
+
...page ? { page } : {},
|
|
876
|
+
steps: session.steps
|
|
877
|
+
};
|
|
878
|
+
await writeFile(sessionPath, stringify2(sessionData), "utf-8");
|
|
879
|
+
const hasInjectable = session.steps.some(
|
|
880
|
+
(s) => s.type === "browser.input" && s.injectable !== false
|
|
881
|
+
);
|
|
882
|
+
sessionRefs.push({
|
|
883
|
+
file: fileName,
|
|
884
|
+
injectable: hasInjectable
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
if (options.authConfig) {
|
|
888
|
+
await mkdir(join(dirPath, "auth"), { recursive: true });
|
|
889
|
+
await writeFile(
|
|
890
|
+
join(dirPath, "auth", "config.yml"),
|
|
891
|
+
stringify2(options.authConfig),
|
|
892
|
+
"utf-8"
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
if (options.encryptedState) {
|
|
896
|
+
await mkdir(join(dirPath, "auth"), { recursive: true });
|
|
897
|
+
await writeFile(
|
|
898
|
+
join(dirPath, "auth", "state.enc"),
|
|
899
|
+
options.encryptedState,
|
|
900
|
+
"utf-8"
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
if (options.requests && options.requests.length > 0) {
|
|
904
|
+
await mkdir(join(dirPath, "requests"), { recursive: true });
|
|
905
|
+
for (const req of options.requests) {
|
|
906
|
+
const safeName = slugify(req.sessionName);
|
|
907
|
+
await writeFile(
|
|
908
|
+
join(dirPath, "requests", `${safeName}.json`),
|
|
909
|
+
JSON.stringify(req, null, 2),
|
|
910
|
+
"utf-8"
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const manifest = {
|
|
915
|
+
version: "2",
|
|
916
|
+
name: options.name,
|
|
917
|
+
target: options.target,
|
|
918
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
919
|
+
driver: options.driver,
|
|
920
|
+
driverConfig: options.driverConfig,
|
|
921
|
+
...options.authConfig ? {
|
|
922
|
+
auth: {
|
|
923
|
+
strategy: options.authConfig.strategy,
|
|
924
|
+
configFile: "auth/config.yml",
|
|
925
|
+
stateFile: options.encryptedState ? "auth/state.enc" : void 0,
|
|
926
|
+
loggedInIndicator: options.authConfig.loggedInIndicator,
|
|
927
|
+
loggedOutIndicator: options.authConfig.loggedOutIndicator
|
|
928
|
+
}
|
|
929
|
+
} : {},
|
|
930
|
+
sessions: sessionRefs,
|
|
931
|
+
scan: {
|
|
932
|
+
tier: "auto",
|
|
933
|
+
parallel: 1,
|
|
934
|
+
timeout: 12e4
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
await writeFile(join(dirPath, "manifest.yml"), stringify2(manifest), "utf-8");
|
|
938
|
+
}
|
|
939
|
+
async function readAuthState(dirPath) {
|
|
940
|
+
const statePath = join(dirPath, "auth", "state.enc");
|
|
941
|
+
if (!existsSync2(statePath)) return null;
|
|
942
|
+
return readFile2(statePath, "utf-8");
|
|
943
|
+
}
|
|
944
|
+
async function readCapturedRequests(dirPath) {
|
|
945
|
+
const requestsDir = join(dirPath, "requests");
|
|
946
|
+
if (!existsSync2(requestsDir)) return [];
|
|
947
|
+
const files = await readdir(requestsDir);
|
|
948
|
+
const requests = [];
|
|
949
|
+
for (const file of files) {
|
|
950
|
+
if (!file.endsWith(".json")) continue;
|
|
951
|
+
const content = await readFile2(join(requestsDir, file), "utf-8");
|
|
952
|
+
requests.push(JSON.parse(content));
|
|
953
|
+
}
|
|
954
|
+
return requests;
|
|
955
|
+
}
|
|
956
|
+
function resolveUrl(target, page) {
|
|
957
|
+
if (!page) return target;
|
|
958
|
+
if (page.startsWith("http")) return page;
|
|
959
|
+
const base = target.replace(/\/$/, "");
|
|
960
|
+
const path = page.startsWith("/") ? page : `/${page}`;
|
|
961
|
+
return `${base}${path}`;
|
|
962
|
+
}
|
|
963
|
+
function slugify(text) {
|
|
964
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
965
|
+
}
|
|
619
966
|
export {
|
|
620
967
|
DRIVER_API_VERSION,
|
|
621
968
|
DriverManager,
|
|
622
969
|
PLUGIN_API_VERSION,
|
|
623
970
|
PluginManager,
|
|
971
|
+
decrypt,
|
|
972
|
+
decryptCredentials,
|
|
973
|
+
decryptStorageState,
|
|
624
974
|
driverManager,
|
|
625
|
-
|
|
975
|
+
encrypt,
|
|
976
|
+
encryptCredentials,
|
|
977
|
+
encryptStorageState,
|
|
978
|
+
getPassphrase,
|
|
979
|
+
isSessionDir,
|
|
980
|
+
loadSessionDir,
|
|
981
|
+
looksLikeSessionDir,
|
|
982
|
+
pluginManager,
|
|
983
|
+
readAuthState,
|
|
984
|
+
readCapturedRequests,
|
|
985
|
+
saveSessionDir
|
|
626
986
|
};
|
|
627
987
|
//# sourceMappingURL=index.js.map
|