@vulcn/engine 0.5.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/dist/index.js CHANGED
@@ -156,23 +156,17 @@ var DriverManager = class {
156
156
  /**
157
157
  * Execute a session
158
158
  * Invokes plugin hooks (onRunStart, onRunEnd) around the driver runner.
159
+ * Plugin onRunStart is deferred until the driver signals the page is ready
160
+ * via the onPageReady callback, ensuring plugins get a real page object.
159
161
  */
160
162
  async execute(session, pluginManager2, options = {}) {
161
163
  const driver = this.getForSession(session);
162
164
  const findings = [];
163
165
  const logger = this.createLogger(driver.name);
164
- const ctx = {
165
- session,
166
- pluginManager: pluginManager2,
167
- payloads: pluginManager2.getPayloads(),
168
- findings,
169
- addFinding: (finding) => {
170
- findings.push(finding);
171
- pluginManager2.addFinding(finding);
172
- options.onFinding?.(finding);
173
- },
174
- logger,
175
- options
166
+ const addFinding = (finding) => {
167
+ findings.push(finding);
168
+ pluginManager2.addFinding(finding);
169
+ options.onFinding?.(finding);
176
170
  };
177
171
  const pluginCtx = {
178
172
  session,
@@ -182,21 +176,57 @@ var DriverManager = class {
182
176
  engine: { version: "0.3.0", pluginApiVersion: 1 },
183
177
  payloads: pluginManager2.getPayloads(),
184
178
  findings,
179
+ addFinding,
185
180
  logger,
186
181
  fetch: globalThis.fetch
187
182
  };
188
- for (const loaded of pluginManager2.getPlugins()) {
189
- if (loaded.enabled && loaded.plugin.hooks?.onRunStart) {
190
- try {
191
- await loaded.plugin.hooks.onRunStart({
192
- ...pluginCtx,
193
- config: loaded.config
194
- });
195
- } catch (err) {
196
- logger.warn(`Plugin ${loaded.plugin.name} onRunStart failed: ${err}`);
183
+ const ctx = {
184
+ session,
185
+ pluginManager: pluginManager2,
186
+ payloads: pluginManager2.getPayloads(),
187
+ findings,
188
+ addFinding,
189
+ logger,
190
+ options: {
191
+ ...options,
192
+ // Provide onPageReady callback — fires plugin onRunStart hooks
193
+ // with the real page object once the driver has created it
194
+ onPageReady: async (page) => {
195
+ pluginCtx.page = page;
196
+ for (const loaded of pluginManager2.getPlugins()) {
197
+ if (loaded.enabled && loaded.plugin.hooks?.onRunStart) {
198
+ try {
199
+ await loaded.plugin.hooks.onRunStart({
200
+ ...pluginCtx,
201
+ config: loaded.config
202
+ });
203
+ } catch (err) {
204
+ logger.warn(
205
+ `Plugin ${loaded.plugin.name} onRunStart failed: ${err}`
206
+ );
207
+ }
208
+ }
209
+ }
210
+ },
211
+ // Fires before browser closes — lets plugins flush pending async work
212
+ onBeforeClose: async (_page) => {
213
+ for (const loaded of pluginManager2.getPlugins()) {
214
+ if (loaded.enabled && loaded.plugin.hooks?.onBeforeClose) {
215
+ try {
216
+ await loaded.plugin.hooks.onBeforeClose({
217
+ ...pluginCtx,
218
+ config: loaded.config
219
+ });
220
+ } catch (err) {
221
+ logger.warn(
222
+ `Plugin ${loaded.plugin.name} onBeforeClose failed: ${err}`
223
+ );
224
+ }
225
+ }
226
+ }
197
227
  }
198
228
  }
199
- }
229
+ };
200
230
  let result = await driver.runner.execute(session, ctx);
201
231
  for (const loaded of pluginManager2.getPlugins()) {
202
232
  if (loaded.enabled && loaded.plugin.hooks?.onRunEnd) {
@@ -213,6 +243,109 @@ var DriverManager = class {
213
243
  }
214
244
  return result;
215
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
+ }
216
349
  /**
217
350
  * Validate driver structure
218
351
  */
@@ -487,6 +620,9 @@ var PluginManager = class {
487
620
  engine: engineInfo,
488
621
  payloads: this.sharedPayloads,
489
622
  findings: this.sharedFindings,
623
+ addFinding: (finding) => {
624
+ this.sharedFindings.push(finding);
625
+ },
490
626
  logger: this.createLogger("plugin"),
491
627
  fetch: globalThis.fetch
492
628
  };
@@ -583,12 +719,269 @@ var PluginManager = class {
583
719
  }
584
720
  };
585
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
+ }
586
966
  export {
587
967
  DRIVER_API_VERSION,
588
968
  DriverManager,
589
969
  PLUGIN_API_VERSION,
590
970
  PluginManager,
971
+ decrypt,
972
+ decryptCredentials,
973
+ decryptStorageState,
591
974
  driverManager,
592
- pluginManager
975
+ encrypt,
976
+ encryptCredentials,
977
+ encryptStorageState,
978
+ getPassphrase,
979
+ isSessionDir,
980
+ loadSessionDir,
981
+ looksLikeSessionDir,
982
+ pluginManager,
983
+ readAuthState,
984
+ readCapturedRequests,
985
+ saveSessionDir
593
986
  };
594
987
  //# sourceMappingURL=index.js.map