@vulcn/engine 0.7.0 → 0.9.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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5011ca5: Add Tier 1 HTTP fast scanner for high-speed payload testing
8
+ - **`httpScan()`** — replay captured HTTP requests via `fetch()` at ~50ms/payload, detecting reflected XSS, error-based SQLi, and server-side reflection without launching a browser
9
+ - **`buildCapturedRequests()`** — convert crawler-discovered forms into `CapturedRequest` metadata for Tier 1 scanning
10
+ - **`CrawlResult`** — `crawlAndBuildSessions()` now returns both `Session[]` (Tier 2 browser replay) and `CapturedRequest[]` (Tier 1 HTTP scan)
11
+ - Tier 1 findings are tagged with `metadata.detectionMethod: "tier1-http"` and `metadata.needsBrowserConfirmation: true` for escalation to Tier 2
12
+ - Supports payload injection into URL params (GET), form-urlencoded bodies, JSON bodies, and multipart form data
13
+ - Configurable concurrency, timeout, cookies, and custom headers via `HttpScanOptions`
14
+ - Progress callbacks for real-time scan monitoring
15
+ - 14 new tests covering XSS/SQLi detection, safe encoding, timeouts, and `buildCapturedRequests` form conversion
16
+
17
+ ## 0.8.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 15d8504: ### Authenticated Scanning
22
+
23
+ End-to-end support for scanning applications behind login pages.
24
+
25
+ #### `@vulcn/engine`
26
+ - **Credential encryption module** (`src/auth.ts`): AES-256-GCM encryption/decryption for credentials and Playwright storage state, with PBKDF2 key derivation (600k iterations)
27
+ - **Auth types**: `FormCredentials`, `HeaderCredentials`, `AuthConfig` with session expiry detection config
28
+ - **Scan-level hooks**: `onScanStart` / `onScanEnd` — fire once per scan wrapping all sessions, with `ScanContext` providing full session list and scan metadata
29
+ - **`onScanEnd` result transformation**: uses `callHookPipe` so plugins can transform the aggregate `RunResult` (e.g. deduplication, risk scoring)
30
+ - **v2 session format**: `.vulcn/` directory structure with manifest, encrypted auth state, and config alongside session files
31
+ - **`CrawlOptions.storageState`**: pass authenticated browser state (cookies + localStorage) to the crawler
32
+ - **New exports**: `ScanContext`, `encryptCredentials`, `decryptCredentials`, `encryptStorageState`, `decryptStorageState`, `getPassphrase`
33
+
34
+ #### `@vulcn/driver-browser`
35
+ - **Authenticated crawling**: `crawlAndBuildSessions` accepts `storageState` via `CrawlOptions` and injects it into the Playwright browser context
36
+ - **Authenticated scanning**: `BrowserRunner` reads `storageState` from `RunOptions` and applies it to the scanner's browser context
37
+ - **Login form auto-detection**: `performLogin` navigates to the login URL, auto-detects username/password fields, fills credentials, and submits
38
+ - **Storage state capture**: after successful login, captures full browser storage state (cookies, localStorage, sessionStorage)
39
+
40
+ #### `vulcn` (CLI)
41
+ - **`vulcn store`**: new command to encrypt and save credentials (form-based or header-based) to `.vulcn/auth.enc`
42
+ - **`vulcn crawl --creds`**: decrypt credentials → perform login → capture storage state → crawl all authenticated pages
43
+ - **`vulcn run --creds`**: decrypt credentials → perform login → inject storage state into scanner browser context → run all payloads authenticated
44
+ - **Auth state persistence**: crawl saves encrypted auth state + config alongside sessions in the output directory
45
+
3
46
  ## 0.7.0
4
47
 
5
48
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -34,8 +34,21 @@ __export(index_exports, {
34
34
  DriverManager: () => DriverManager,
35
35
  PLUGIN_API_VERSION: () => PLUGIN_API_VERSION,
36
36
  PluginManager: () => PluginManager,
37
+ decrypt: () => decrypt,
38
+ decryptCredentials: () => decryptCredentials,
39
+ decryptStorageState: () => decryptStorageState,
37
40
  driverManager: () => driverManager,
38
- pluginManager: () => pluginManager
41
+ encrypt: () => encrypt,
42
+ encryptCredentials: () => encryptCredentials,
43
+ encryptStorageState: () => encryptStorageState,
44
+ getPassphrase: () => getPassphrase,
45
+ isSessionDir: () => isSessionDir,
46
+ loadSessionDir: () => loadSessionDir,
47
+ looksLikeSessionDir: () => looksLikeSessionDir,
48
+ pluginManager: () => pluginManager,
49
+ readAuthState: () => readAuthState,
50
+ readCapturedRequests: () => readCapturedRequests,
51
+ saveSessionDir: () => saveSessionDir
39
52
  });
40
53
  module.exports = __toCommonJS(index_exports);
41
54
 
@@ -284,6 +297,109 @@ var DriverManager = class {
284
297
  }
285
298
  return result;
286
299
  }
300
+ /**
301
+ * Execute multiple sessions with a shared browser (scan-level orchestration).
302
+ *
303
+ * This is the preferred entry point for running a full scan. It:
304
+ * 1. Launches ONE browser for the entire scan
305
+ * 2. Passes the browser to each session's runner via options.browser
306
+ * 3. Each session creates its own context (lightweight, isolated cookies)
307
+ * 4. Aggregates results across all sessions
308
+ * 5. Closes the browser once at the end
309
+ *
310
+ * This is 5-10x faster than calling execute() per session because
311
+ * launching a browser takes 2-3 seconds.
312
+ */
313
+ async executeScan(sessions, pluginManager2, options = {}) {
314
+ if (sessions.length === 0) {
315
+ const empty = {
316
+ findings: [],
317
+ stepsExecuted: 0,
318
+ payloadsTested: 0,
319
+ duration: 0,
320
+ errors: ["No sessions to execute"]
321
+ };
322
+ return { results: [], aggregate: empty };
323
+ }
324
+ const startTime = Date.now();
325
+ const results = [];
326
+ const allFindings = [];
327
+ let totalSteps = 0;
328
+ let totalPayloads = 0;
329
+ const allErrors = [];
330
+ const firstDriver = this.getForSession(sessions[0]);
331
+ let sharedBrowser = null;
332
+ if (firstDriver.name === "browser") {
333
+ try {
334
+ const driverPkg = "@vulcn/driver-browser";
335
+ const { launchBrowser } = await import(
336
+ /* @vite-ignore */
337
+ driverPkg
338
+ );
339
+ const browserType = sessions[0].driverConfig.browser ?? "chromium";
340
+ const headless = options.headless ?? true;
341
+ const result = await launchBrowser({
342
+ browser: browserType,
343
+ headless
344
+ });
345
+ sharedBrowser = result.browser;
346
+ } catch {
347
+ }
348
+ }
349
+ try {
350
+ await pluginManager2.callHook("onScanStart", async (hook, ctx) => {
351
+ const scanCtx = {
352
+ ...ctx,
353
+ sessions,
354
+ headless: options.headless ?? true,
355
+ sessionCount: sessions.length
356
+ };
357
+ await hook(scanCtx);
358
+ });
359
+ for (const session of sessions) {
360
+ const sessionOptions = {
361
+ ...options,
362
+ ...sharedBrowser ? { browser: sharedBrowser } : {}
363
+ };
364
+ const result = await this.execute(
365
+ session,
366
+ pluginManager2,
367
+ sessionOptions
368
+ );
369
+ results.push(result);
370
+ allFindings.push(...result.findings);
371
+ totalSteps += result.stepsExecuted;
372
+ totalPayloads += result.payloadsTested;
373
+ allErrors.push(...result.errors);
374
+ }
375
+ } finally {
376
+ if (sharedBrowser && typeof sharedBrowser.close === "function") {
377
+ await sharedBrowser.close();
378
+ }
379
+ }
380
+ const aggregate = {
381
+ findings: allFindings,
382
+ stepsExecuted: totalSteps,
383
+ payloadsTested: totalPayloads,
384
+ duration: Date.now() - startTime,
385
+ errors: allErrors
386
+ };
387
+ let finalAggregate = aggregate;
388
+ finalAggregate = await pluginManager2.callHookPipe(
389
+ "onScanEnd",
390
+ finalAggregate,
391
+ async (hook, value, ctx) => {
392
+ const scanCtx = {
393
+ ...ctx,
394
+ sessions,
395
+ headless: options.headless ?? true,
396
+ sessionCount: sessions.length
397
+ };
398
+ return await hook(value, scanCtx);
399
+ }
400
+ );
401
+ return { results, aggregate: finalAggregate };
402
+ }
287
403
  /**
288
404
  * Validate driver structure
289
405
  */
@@ -657,13 +773,265 @@ var PluginManager = class {
657
773
  }
658
774
  };
659
775
  var pluginManager = new PluginManager();
776
+
777
+ // src/auth.ts
778
+ var import_node_crypto = require("crypto");
779
+ var ALGORITHM = "aes-256-gcm";
780
+ var KEY_LENGTH = 32;
781
+ var IV_LENGTH = 16;
782
+ var SALT_LENGTH = 32;
783
+ var PBKDF2_ITERATIONS = 1e5;
784
+ var PBKDF2_DIGEST = "sha512";
785
+ function deriveKey(passphrase, salt) {
786
+ return (0, import_node_crypto.pbkdf2Sync)(
787
+ passphrase,
788
+ salt,
789
+ PBKDF2_ITERATIONS,
790
+ KEY_LENGTH,
791
+ PBKDF2_DIGEST
792
+ );
793
+ }
794
+ function encrypt(data, passphrase) {
795
+ const salt = (0, import_node_crypto.randomBytes)(SALT_LENGTH);
796
+ const iv = (0, import_node_crypto.randomBytes)(IV_LENGTH);
797
+ const key = deriveKey(passphrase, salt);
798
+ const cipher = (0, import_node_crypto.createCipheriv)(ALGORITHM, key, iv);
799
+ let encrypted = cipher.update(data, "utf8", "hex");
800
+ encrypted += cipher.final("hex");
801
+ const tag = cipher.getAuthTag();
802
+ const payload = {
803
+ version: 1,
804
+ salt: salt.toString("hex"),
805
+ iv: iv.toString("hex"),
806
+ tag: tag.toString("hex"),
807
+ data: encrypted,
808
+ iterations: PBKDF2_ITERATIONS
809
+ };
810
+ return JSON.stringify(payload);
811
+ }
812
+ function decrypt(encrypted, passphrase) {
813
+ const payload = JSON.parse(encrypted);
814
+ if (payload.version !== 1) {
815
+ throw new Error(`Unsupported encryption version: ${payload.version}`);
816
+ }
817
+ const salt = Buffer.from(payload.salt, "hex");
818
+ const iv = Buffer.from(payload.iv, "hex");
819
+ const tag = Buffer.from(payload.tag, "hex");
820
+ const key = deriveKey(passphrase, salt);
821
+ const decipher = (0, import_node_crypto.createDecipheriv)(ALGORITHM, key, iv);
822
+ decipher.setAuthTag(tag);
823
+ let decrypted = decipher.update(payload.data, "hex", "utf8");
824
+ decrypted += decipher.final("utf8");
825
+ return decrypted;
826
+ }
827
+ function encryptCredentials(credentials, passphrase) {
828
+ return encrypt(JSON.stringify(credentials), passphrase);
829
+ }
830
+ function decryptCredentials(encrypted, passphrase) {
831
+ const json = decrypt(encrypted, passphrase);
832
+ return JSON.parse(json);
833
+ }
834
+ function encryptStorageState(storageState, passphrase) {
835
+ return encrypt(storageState, passphrase);
836
+ }
837
+ function decryptStorageState(encrypted, passphrase) {
838
+ return decrypt(encrypted, passphrase);
839
+ }
840
+ function getPassphrase(interactive) {
841
+ if (interactive) return interactive;
842
+ const envKey = process.env.VULCN_KEY;
843
+ if (envKey) return envKey;
844
+ throw new Error(
845
+ "No passphrase provided. Set VULCN_KEY environment variable or pass --passphrase."
846
+ );
847
+ }
848
+
849
+ // src/session.ts
850
+ var import_promises2 = require("fs/promises");
851
+ var import_node_fs2 = require("fs");
852
+ var import_node_path3 = require("path");
853
+ var import_yaml3 = require("yaml");
854
+ async function loadSessionDir(dirPath) {
855
+ const manifestPath = (0, import_node_path3.join)(dirPath, "manifest.yml");
856
+ if (!(0, import_node_fs2.existsSync)(manifestPath)) {
857
+ throw new Error(
858
+ `No manifest.yml found in ${dirPath}. Is this a v2 session directory?`
859
+ );
860
+ }
861
+ const manifestYaml = await (0, import_promises2.readFile)(manifestPath, "utf-8");
862
+ const manifest = (0, import_yaml3.parse)(manifestYaml);
863
+ if (manifest.version !== "2") {
864
+ throw new Error(
865
+ `Unsupported session format version: ${manifest.version}. Expected "2".`
866
+ );
867
+ }
868
+ let authConfig;
869
+ if (manifest.auth?.configFile) {
870
+ const authPath = (0, import_node_path3.join)(dirPath, manifest.auth.configFile);
871
+ if ((0, import_node_fs2.existsSync)(authPath)) {
872
+ const authYaml = await (0, import_promises2.readFile)(authPath, "utf-8");
873
+ authConfig = (0, import_yaml3.parse)(authYaml);
874
+ }
875
+ }
876
+ const sessions = [];
877
+ for (const ref of manifest.sessions) {
878
+ if (ref.injectable === false) continue;
879
+ const sessionPath = (0, import_node_path3.join)(dirPath, ref.file);
880
+ if (!(0, import_node_fs2.existsSync)(sessionPath)) {
881
+ console.warn(`Session file not found: ${sessionPath}, skipping`);
882
+ continue;
883
+ }
884
+ const sessionYaml = await (0, import_promises2.readFile)(sessionPath, "utf-8");
885
+ const sessionData = (0, import_yaml3.parse)(sessionYaml);
886
+ const session = {
887
+ name: sessionData.name ?? (0, import_node_path3.basename)(ref.file, ".yml"),
888
+ driver: manifest.driver,
889
+ driverConfig: {
890
+ ...manifest.driverConfig,
891
+ startUrl: resolveUrl(
892
+ manifest.target,
893
+ sessionData.page
894
+ )
895
+ },
896
+ steps: sessionData.steps ?? [],
897
+ metadata: {
898
+ recordedAt: manifest.recordedAt,
899
+ version: "2",
900
+ manifestDir: dirPath
901
+ }
902
+ };
903
+ sessions.push(session);
904
+ }
905
+ return { manifest, sessions, authConfig };
906
+ }
907
+ function isSessionDir(path) {
908
+ return (0, import_node_fs2.existsSync)((0, import_node_path3.join)(path, "manifest.yml"));
909
+ }
910
+ function looksLikeSessionDir(path) {
911
+ return path.endsWith(".vulcn") || path.endsWith(".vulcn/");
912
+ }
913
+ async function saveSessionDir(dirPath, options) {
914
+ await (0, import_promises2.mkdir)((0, import_node_path3.join)(dirPath, "sessions"), { recursive: true });
915
+ const sessionRefs = [];
916
+ for (const session of options.sessions) {
917
+ const safeName = slugify(session.name);
918
+ const fileName = `sessions/${safeName}.yml`;
919
+ const sessionPath = (0, import_node_path3.join)(dirPath, fileName);
920
+ const startUrl = session.driverConfig.startUrl;
921
+ const page = startUrl ? startUrl.replace(options.target, "").replace(/^\//, "/") : void 0;
922
+ const sessionData = {
923
+ name: session.name,
924
+ ...page ? { page } : {},
925
+ steps: session.steps
926
+ };
927
+ await (0, import_promises2.writeFile)(sessionPath, (0, import_yaml3.stringify)(sessionData), "utf-8");
928
+ const hasInjectable = session.steps.some(
929
+ (s) => s.type === "browser.input" && s.injectable !== false
930
+ );
931
+ sessionRefs.push({
932
+ file: fileName,
933
+ injectable: hasInjectable
934
+ });
935
+ }
936
+ if (options.authConfig) {
937
+ await (0, import_promises2.mkdir)((0, import_node_path3.join)(dirPath, "auth"), { recursive: true });
938
+ await (0, import_promises2.writeFile)(
939
+ (0, import_node_path3.join)(dirPath, "auth", "config.yml"),
940
+ (0, import_yaml3.stringify)(options.authConfig),
941
+ "utf-8"
942
+ );
943
+ }
944
+ if (options.encryptedState) {
945
+ await (0, import_promises2.mkdir)((0, import_node_path3.join)(dirPath, "auth"), { recursive: true });
946
+ await (0, import_promises2.writeFile)(
947
+ (0, import_node_path3.join)(dirPath, "auth", "state.enc"),
948
+ options.encryptedState,
949
+ "utf-8"
950
+ );
951
+ }
952
+ if (options.requests && options.requests.length > 0) {
953
+ await (0, import_promises2.mkdir)((0, import_node_path3.join)(dirPath, "requests"), { recursive: true });
954
+ for (const req of options.requests) {
955
+ const safeName = slugify(req.sessionName);
956
+ await (0, import_promises2.writeFile)(
957
+ (0, import_node_path3.join)(dirPath, "requests", `${safeName}.json`),
958
+ JSON.stringify(req, null, 2),
959
+ "utf-8"
960
+ );
961
+ }
962
+ }
963
+ const manifest = {
964
+ version: "2",
965
+ name: options.name,
966
+ target: options.target,
967
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
968
+ driver: options.driver,
969
+ driverConfig: options.driverConfig,
970
+ ...options.authConfig ? {
971
+ auth: {
972
+ strategy: options.authConfig.strategy,
973
+ configFile: "auth/config.yml",
974
+ stateFile: options.encryptedState ? "auth/state.enc" : void 0,
975
+ loggedInIndicator: options.authConfig.loggedInIndicator,
976
+ loggedOutIndicator: options.authConfig.loggedOutIndicator
977
+ }
978
+ } : {},
979
+ sessions: sessionRefs,
980
+ scan: {
981
+ tier: "auto",
982
+ parallel: 1,
983
+ timeout: 12e4
984
+ }
985
+ };
986
+ await (0, import_promises2.writeFile)((0, import_node_path3.join)(dirPath, "manifest.yml"), (0, import_yaml3.stringify)(manifest), "utf-8");
987
+ }
988
+ async function readAuthState(dirPath) {
989
+ const statePath = (0, import_node_path3.join)(dirPath, "auth", "state.enc");
990
+ if (!(0, import_node_fs2.existsSync)(statePath)) return null;
991
+ return (0, import_promises2.readFile)(statePath, "utf-8");
992
+ }
993
+ async function readCapturedRequests(dirPath) {
994
+ const requestsDir = (0, import_node_path3.join)(dirPath, "requests");
995
+ if (!(0, import_node_fs2.existsSync)(requestsDir)) return [];
996
+ const files = await (0, import_promises2.readdir)(requestsDir);
997
+ const requests = [];
998
+ for (const file of files) {
999
+ if (!file.endsWith(".json")) continue;
1000
+ const content = await (0, import_promises2.readFile)((0, import_node_path3.join)(requestsDir, file), "utf-8");
1001
+ requests.push(JSON.parse(content));
1002
+ }
1003
+ return requests;
1004
+ }
1005
+ function resolveUrl(target, page) {
1006
+ if (!page) return target;
1007
+ if (page.startsWith("http")) return page;
1008
+ const base = target.replace(/\/$/, "");
1009
+ const path = page.startsWith("/") ? page : `/${page}`;
1010
+ return `${base}${path}`;
1011
+ }
1012
+ function slugify(text) {
1013
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1014
+ }
660
1015
  // Annotate the CommonJS export names for ESM import in node:
661
1016
  0 && (module.exports = {
662
1017
  DRIVER_API_VERSION,
663
1018
  DriverManager,
664
1019
  PLUGIN_API_VERSION,
665
1020
  PluginManager,
1021
+ decrypt,
1022
+ decryptCredentials,
1023
+ decryptStorageState,
666
1024
  driverManager,
667
- pluginManager
1025
+ encrypt,
1026
+ encryptCredentials,
1027
+ encryptStorageState,
1028
+ getPassphrase,
1029
+ isSessionDir,
1030
+ loadSessionDir,
1031
+ looksLikeSessionDir,
1032
+ pluginManager,
1033
+ readAuthState,
1034
+ readCapturedRequests,
1035
+ saveSessionDir
668
1036
  });
669
1037
  //# sourceMappingURL=index.cjs.map