wowdump 0.3.1 → 0.3.3

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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +21 -53
  3. package/dist/adapters/reader.js +33 -0
  4. package/dist/analysis/disassemble.js +77 -0
  5. package/dist/{frida-runtime.js → analysis/frida-runtime.js} +48 -48
  6. package/dist/analysis/runtime-script.js +170 -0
  7. package/dist/cli.js +337 -192
  8. package/dist/core/profile-engine.js +238 -0
  9. package/dist/frida-worker.js +54 -55
  10. package/dist/{reader-broker.js → reader/broker.js} +15 -0
  11. package/dist/{reader-client.js → reader/client.js} +1 -1
  12. package/dist/{windows-launcher.js → reader/launcher.js} +16 -4
  13. package/dist/reader/main.js +100 -0
  14. package/dist/reader/protocol.js +1 -0
  15. package/dist/reader/windows.js +242 -0
  16. package/dist/reader-main.js +1 -66
  17. package/dist/toolchain.js +102 -573
  18. package/package.json +13 -10
  19. package/skills/wowdump/SKILL.md +24 -15
  20. package/skills/wowdump/references/commands.md +77 -0
  21. package/skills/wowdump/references/disassemble.md +62 -0
  22. package/skills/wowdump/references/dynamic.md +54 -0
  23. package/skills/wowdump/references/evidence-workflow.md +41 -0
  24. package/skills/wowdump/references/profiles.md +34 -0
  25. package/skills/wowdump/references/request-schema.md +28 -0
  26. package/skills/wowdump/references/workflow.md +45 -0
  27. package/skills/wowdump/scripts/dynamic-session.js +133 -0
  28. package/dist/agent.js +0 -1332
  29. package/dist/discovery.js +0 -48
  30. package/dist/dry-run.js +0 -36
  31. package/dist/error-log.js +0 -71
  32. package/dist/focused-session.js +0 -89
  33. package/dist/ghidra.js +0 -769
  34. package/dist/main.js +0 -66
  35. package/dist/observability.js +0 -41
  36. package/dist/processes.js +0 -44
  37. package/dist/session.js +0 -42
  38. package/dist/storage.js +0 -12
  39. package/dist/windows-reader.js +0 -102
  40. package/dist/wow-analysis.js +0 -1405
  41. package/skills/wowdump/commands.md +0 -44
  42. /package/dist/{adapters.js → core/build-adapters.js} +0 -0
  43. /package/dist/{types.js → core/types.js} +0 -0
package/dist/cli.js CHANGED
@@ -1,82 +1,106 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, realpathSync } from "node:fs";
4
- import { readFile, readdir } from "node:fs/promises";
5
- import { basename, extname, isAbsolute, join, resolve } from "node:path";
6
- import { pathToFileURL } from "node:url";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, realpathSync, readFileSync } from "node:fs";
5
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { Command } from "commander";
8
- import { WindowsBrokerManager } from "./windows-launcher.js";
9
- import { bootstrapToolchain, resolveToolchain, resolveWowdumpHome } from "./toolchain.js";
10
- import { runGhidraStaticAnalysis as runCanonicalGhidraStaticAnalysis, buildGhidraHeadlessCommand } from "./ghidra.js";
11
- async function runStaticAnalysis(options) {
12
- // ghidra.ts owns the canonical evidence/profile schema. Keep the small
13
- // toolchain resolver as the CLI-facing bootstrap layer, then translate the
14
- // command options into the canonical runner's vocabulary.
15
- const ghidraValue = options.ghidra;
16
- const ghidraResolved = ghidraValue ? resolve(ghidraValue) : undefined;
17
- const ghidraIsFile = ghidraResolved ? existsSync(ghidraResolved) && !existsSync(join(ghidraResolved, "support")) : false;
18
- const canonicalOptions = {
19
- exe: options.executable,
20
- buildKey: options.buildKey,
21
- output: options.evidenceFile ?? join(options.outputDirectory ?? resolveWowdumpHome(options.env ?? process.env), "profiles", `${options.buildKey.replace(/[^A-Za-z0-9_.-]+/g, "-")}.json`),
22
- projectDir: options.projectDirectory,
23
- projectName: options.projectName,
24
- ghidraHome: ghidraResolved && !ghidraIsFile ? ghidraResolved : undefined,
25
- analyzeHeadless: ghidraResolved && ghidraIsFile ? ghidraResolved : undefined,
26
- javaPath: options.java,
27
- scriptPath: options.scriptDirectory,
28
- scriptName: options.exporterScript,
29
- timeoutMs: (options.timeoutSeconds ?? 1800) * 1000,
30
- env: options.env,
31
- home: options.home,
32
- commandRunner: options.runner
33
- ? async (file, args, commandOptions) => {
34
- const value = await options.runner({ file, args: [...args], cwd: commandOptions.cwd, env: commandOptions.env });
35
- return { code: value.exitCode, stdout: value.stdout, stderr: value.stderr };
9
+ import { WindowsBrokerManager } from "./reader/launcher.js";
10
+ import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain, resolveWowdumpHome } from "./toolchain.js";
11
+ import { runDisassembly } from "./analysis/disassemble.js";
12
+ import { RUNTIME_EXPORT_SCRIPT } from "./analysis/runtime-script.js";
13
+ import { ProfileEngine } from "./core/profile-engine.js";
14
+ import { ReaderProfileAdapter } from "./adapters/reader.js";
15
+ import { enumerateWindowsProcesses } from "./reader/windows.js";
16
+ export function parseBuildInfo(content, file) {
17
+ const lines = content.split(/\r?\n/).filter(line => line.trim());
18
+ const headers = lines[0]?.split("|") ?? [];
19
+ const versionIndex = headers.indexOf("Version!STRING:0");
20
+ const activeIndex = headers.indexOf("Active!DEC:1");
21
+ const productIndex = headers.indexOf("Product!STRING:0");
22
+ if (versionIndex < 0 || activeIndex < 0 || productIndex < 0)
23
+ return null;
24
+ const row = lines.slice(1).map(line => line.split("|")).find(values => values[activeIndex] === "1" && values[productIndex] === "wow");
25
+ const version = row?.[versionIndex]?.trim();
26
+ const product = row?.[productIndex]?.trim();
27
+ if (!version || !product)
28
+ return null;
29
+ return { fileVersion: version, product, buildKey: `retail@${version}`, buildInfoFile: file };
30
+ }
31
+ async function discoverWowTargets(selectedPid, elevatedModules) {
32
+ if (process.platform !== "win32")
33
+ return [];
34
+ const targets = [];
35
+ const rows = enumerateWindowsProcesses()
36
+ .filter(item => /^Wow\.exe$/i.test(item.name))
37
+ .filter(item => selectedPid === undefined || item.pid === selectedPid);
38
+ for (const item of rows) {
39
+ const pid = item.pid;
40
+ const name = item.name;
41
+ if (!Number.isSafeInteger(pid) || pid < 1 || !/^Wow\.exe$/i.test(name) || (selectedPid !== undefined && pid !== selectedPid))
42
+ continue;
43
+ let path = item.path;
44
+ let moduleBase = null;
45
+ let moduleSize = null;
46
+ let diagnostic;
47
+ if ((!path || !moduleBase) && elevatedModules) {
48
+ try {
49
+ const elevated = elevatedModules(pid);
50
+ const response = await elevated;
51
+ const modules = response && typeof response === "object" && Array.isArray(response.modules)
52
+ ? response.modules
53
+ : [];
54
+ const main = modules.find(module => /^Wow\.exe$/i.test(String(module.name ?? ""))) ?? modules[0];
55
+ if (!path && typeof main?.path === "string" && main.path)
56
+ path = main.path;
57
+ if (!moduleBase && typeof main?.base === "string")
58
+ moduleBase = main.base;
59
+ if (moduleSize === null && Number.isFinite(Number(main?.size)))
60
+ moduleSize = Number(main?.size);
61
+ }
62
+ catch (error) {
63
+ diagnostic = diagnostic ?? (error instanceof Error ? error.message : String(error));
36
64
  }
37
- : undefined
38
- };
39
- if (options.dryRun) {
40
- const resolved = resolveToolchain(options);
41
- if (!resolved.ghidra || !resolved.java) {
42
- throw new CliError("TOOLCHAIN_INCOMPLETE", `Ghidra toolchain is incomplete: missing ${resolved.missing.join(", ")}`, resolved);
43
65
  }
44
- const projectDir = options.projectDirectory ?? join(resolved.home, "cache", "ghidra", options.buildKey.replace(/[^A-Za-z0-9_.-]+/g, "-"));
45
- const command = buildGhidraHeadlessCommand({ analyzeHeadless: resolved.ghidra.executable, javaPath: resolved.java.executable }, {
46
- exe: options.executable,
47
- buildKey: options.buildKey,
48
- projectDir,
49
- projectName: options.projectName,
50
- scriptPath: options.scriptDirectory,
51
- scriptName: options.exporterScript,
52
- output: options.evidenceFile
53
- });
54
- return {
55
- ok: true,
56
- command: "analyze.static",
57
- dryRun: true,
58
- buildKey: options.buildKey,
59
- executable: resolve(options.executable),
60
- outputFile: options.evidenceFile ?? join(projectDir, `${options.buildKey.replace(/[^A-Za-z0-9_.-]+/g, "-")}.json`),
61
- commandLine: { file: command.executable, args: command.args, cwd: command.cwd },
62
- toolchain: resolved,
63
- profile: { schema: "wowdump.profile.v1", buildKey: options.buildKey, confidence: "candidate" }
64
- };
66
+ let buildInfoFile = null;
67
+ let fileVersion = null;
68
+ let product = null;
69
+ if (path) {
70
+ const candidates = [join(dirname(path), ".build.info"), join(dirname(dirname(path)), ".build.info")];
71
+ for (const candidate of candidates) {
72
+ try {
73
+ const text = await readFile(candidate, "utf8");
74
+ const parsed = parseBuildInfo(text, candidate);
75
+ if (parsed) {
76
+ fileVersion = parsed.fileVersion;
77
+ product = parsed.product;
78
+ buildInfoFile = parsed.buildInfoFile;
79
+ break;
80
+ }
81
+ }
82
+ catch { /* try the next parent directory */ }
83
+ }
84
+ }
85
+ targets.push({ pid, name, path, moduleBase, moduleSize, fileVersion, product, buildKey: fileVersion ? `retail@${fileVersion}` : null, ...(buildInfoFile ? { buildInfoFile } : {}), ...(diagnostic ? { diagnostics: { frida: diagnostic } } : {}) });
65
86
  }
66
- const result = await runCanonicalGhidraStaticAnalysis(canonicalOptions);
67
- return {
68
- ok: true,
69
- command: "analyze.static",
70
- dryRun: false,
71
- buildKey: result.buildKey,
72
- executable: result.executable.path,
73
- executableSha256: result.executable.sha256,
74
- outputFile: options.evidenceFile ?? join(options.outputDirectory ?? resolveWowdumpHome(options.env ?? process.env), "profiles", `${options.buildKey.replace(/[^A-Za-z0-9_.-]+/g, "-")}.json`),
75
- commandLine: { file: result.analysis.command[0] ?? "", args: result.analysis.command.slice(1), cwd: options.projectDirectory ?? resolveWowdumpHome(options.env ?? process.env) },
76
- toolchain: resolveToolchain(options),
77
- profile: result.profile,
78
- evidence: result
79
- };
87
+ return targets;
88
+ }
89
+ function packageVersion() {
90
+ const override = process.env.WOWDUMP_VERSION?.trim();
91
+ if (override)
92
+ return override;
93
+ try {
94
+ const packageFile = fileURLToPath(new URL("../package.json", import.meta.url));
95
+ const packageJson = JSON.parse(readFileSync(packageFile, "utf8"));
96
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
97
+ }
98
+ catch {
99
+ return "0.0.0";
100
+ }
101
+ }
102
+ function installedDistDirectory() {
103
+ return fileURLToPath(new URL(".", import.meta.url));
80
104
  }
81
105
  class CliError extends Error {
82
106
  code;
@@ -133,17 +157,17 @@ async function defaultSidecar(file, args, input) {
133
157
  child.stdin.end();
134
158
  });
135
159
  }
136
- function brokerCommandLine(env, cwd) {
160
+ function brokerCommandLine(env, packageDist) {
137
161
  const configured = env.WOWDUMP_READER_COMMAND?.trim();
138
162
  if (configured)
139
163
  return { file: configured, args: [] };
140
- const candidate = resolve(cwd, "dist", "reader-main.js");
164
+ const candidate = join(packageDist, "reader-main.js");
141
165
  return existsSync(candidate) ? { file: process.execPath, args: [candidate, "--request-stdio"] } : null;
142
166
  }
143
- async function defaultBroker(invocation, env, cwd, sidecar) {
144
- const command = brokerCommandLine(env, cwd);
167
+ async function defaultBroker(invocation, env, packageDist, sidecar) {
168
+ const command = brokerCommandLine(env, packageDist);
145
169
  if (!command) {
146
- throw new CliError("BROKER_NOT_CONFIGURED", "reader broker entry was not found", { expected: resolve(cwd, "dist", "reader-main.js"), environment: "WOWDUMP_READER_COMMAND" });
170
+ throw new CliError("BROKER_NOT_CONFIGURED", "reader broker entry was not found", { expected: join(packageDist, "reader-main.js"), environment: "WOWDUMP_READER_COMMAND" });
147
171
  }
148
172
  const result = await (sidecar ?? defaultSidecar)(command.file, command.args, `${JSON.stringify(invocation)}\n`);
149
173
  if (result.exitCode !== 0)
@@ -158,12 +182,28 @@ async function defaultBroker(invocation, env, cwd, sidecar) {
158
182
  throw new CliError("BROKER_PROTOCOL_ERROR", "reader broker returned invalid JSON", { stdout: result.stdout });
159
183
  }
160
184
  }
185
+ async function runFridaWorker(worker, input, timeoutMs, broker) {
186
+ if (process.platform !== "win32") {
187
+ throw new CliError("PLATFORM_UNSUPPORTED", "elevated Frida broker is currently supported on Windows only");
188
+ }
189
+ const raw = await broker({ command: "frida", payload: { worker, input, timeoutMs } });
190
+ const response = raw && typeof raw === "object" ? raw : {};
191
+ if (response.ok === false) {
192
+ const error = response.error && typeof response.error === "object" ? response.error : {};
193
+ throw new CliError("BROKER_FAILED", String(error.message ?? "elevated Frida worker failed"), { response });
194
+ }
195
+ return {
196
+ exitCode: Number.isSafeInteger(Number(response.exitCode)) ? Number(response.exitCode) : 1,
197
+ stdout: typeof response.stdout === "string" ? response.stdout : "",
198
+ stderr: typeof response.stderr === "string" ? response.stderr : ""
199
+ };
200
+ }
161
201
  const windowsBrokerManagers = new Map();
162
- function persistentWindowsBroker(home, cwd) {
202
+ function persistentWindowsBroker(home, packageDist) {
163
203
  const key = resolve(home).toLowerCase();
164
204
  let manager = windowsBrokerManagers.get(key);
165
205
  if (!manager) {
166
- manager = new WindowsBrokerManager({ home, readerEntry: resolve(cwd, "dist", "reader-main.js") });
206
+ manager = new WindowsBrokerManager({ home, readerEntry: join(packageDist, "reader-main.js") });
167
207
  windowsBrokerManagers.set(key, manager);
168
208
  }
169
209
  return manager;
@@ -209,10 +249,10 @@ function selected(source, keys) {
209
249
  function requireRuntimeConfirmation(options) {
210
250
  if (options.confirm === true)
211
251
  return;
212
- throw new CliError("CONFIRMATION_REQUIRED", "runtime export requires --confirm", {
252
+ throw new CliError("CONFIRMATION_REQUIRED", "runtime analysis requires --confirm", {
213
253
  pid: options.pid,
214
254
  buildKey: options.build,
215
- operation: "runtime-export",
255
+ operation: "runtime",
216
256
  kind: options.kind,
217
257
  maxHooks: options.maxHooks,
218
258
  durationMs: options.durationMs,
@@ -220,53 +260,102 @@ function requireRuntimeConfirmation(options) {
220
260
  cleanupDeadlineMs: options.cleanupDeadlineMs
221
261
  });
222
262
  }
223
- function nested(record, ...path) {
224
- let value = record;
225
- for (const key of path) {
226
- if (!value || typeof value !== "object" || Array.isArray(value))
227
- return undefined;
228
- value = value[key];
229
- }
263
+ function safeBuildDirectory(buildKey) {
264
+ const value = buildKey.trim();
265
+ if (!value || !/^[A-Za-z0-9_.@-]+$/.test(value))
266
+ throw new CliError("ARGUMENT_INVALID", "build key contains unsupported path characters");
230
267
  return value;
231
268
  }
232
- function compareEvidence(staticProfile, runtimeExport) {
233
- const definitions = [
234
- { name: "buildKey", left: staticProfile.buildKey, right: runtimeExport.buildKey },
235
- {
236
- name: "executableSha256",
237
- left: staticProfile.executableSha256 ?? nested(staticProfile, "executable", "sha256"),
238
- right: runtimeExport.executableSha256 ?? nested(runtimeExport, "executable", "sha256")
239
- },
240
- { name: "moduleBase", left: staticProfile.moduleBase ?? nested(staticProfile, "module", "base"), right: runtimeExport.moduleBase ?? nested(runtimeExport, "module", "base") },
241
- { name: "sectionBounds", left: staticProfile.sectionBounds ?? staticProfile.sections, right: runtimeExport.sectionBounds ?? runtimeExport.sections },
242
- { name: "entryBytes", left: staticProfile.entryBytes, right: runtimeExport.entryBytes },
243
- { name: "matchCount", left: staticProfile.matchCount, right: runtimeExport.matchCount }
244
- ];
245
- return definitions.map(item => ({
246
- name: item.name,
247
- static: item.left ?? null,
248
- runtime: item.right ?? null,
249
- status: item.left === undefined || item.right === undefined
250
- ? "missing"
251
- : JSON.stringify(item.left) === JSON.stringify(item.right) ? "match" : "mismatch"
252
- }));
269
+ function decodeBase64(value, expectedSize, label) {
270
+ if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
271
+ throw new CliError("RUNTIME_DUMP_INVALID", `${label} is not valid base64`);
272
+ const data = Buffer.from(value, "base64");
273
+ if (data.length !== expectedSize)
274
+ throw new CliError("RUNTIME_DUMP_INVALID", `${label} size does not match metadata`);
275
+ return data;
276
+ }
277
+ export async function persistRuntimeDump(value, outputDirectory) {
278
+ const sections = Array.isArray(value.sections) ? value.sections : [];
279
+ if (value.ok !== true || sections.length === 0)
280
+ return value;
281
+ const directory = resolve(outputDirectory);
282
+ await mkdir(directory, { recursive: true });
283
+ const summaries = [];
284
+ for (const raw of sections) {
285
+ const section = jsonRecord(JSON.stringify(raw), "runtime section");
286
+ const name = typeof section.name === "string" && /^[A-Za-z0-9_.-]+$/.test(section.name) ? section.name : "section";
287
+ const file = join(directory, `${name}.bin`);
288
+ const chunks = Array.isArray(section.chunks) ? section.chunks : [];
289
+ const buffers = chunks.map((chunk, index) => {
290
+ const item = jsonRecord(JSON.stringify(chunk), `runtime section ${name} chunk ${index}`);
291
+ return decodeBase64(item.dataBase64, Number(item.size), `${name} chunk ${index}`);
292
+ });
293
+ const data = Buffer.concat(buffers);
294
+ await writeFile(file, data);
295
+ summaries.push({
296
+ ...selected(section, ["name", "rva", "runtimeAddress", "virtualSize", "rawSize", "characteristics", "protection", "requestedSize", "readSize", "truncated"]),
297
+ file,
298
+ bytes: data.length,
299
+ sha256: createHash("sha256").update(data).digest("hex")
300
+ });
301
+ }
302
+ const manifest = {
303
+ schema: "wowdump.runtime-dump.v1",
304
+ kind: "dump",
305
+ buildKey: value.buildKey ?? null,
306
+ pid: value.pid ?? null,
307
+ module: value.module ?? null,
308
+ totalBytes: value.totalBytes ?? summaries.reduce((total, item) => total + Number(item.bytes ?? 0), 0),
309
+ truncated: value.truncated === true || summaries.some(item => item.truncated === true),
310
+ limits: value.limits ?? null,
311
+ sections: summaries,
312
+ evidence: value.evidence ?? [],
313
+ generatedAt: new Date().toISOString()
314
+ };
315
+ const manifestFile = join(directory, "manifest.json");
316
+ await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
317
+ return { ...value, sections: summaries, outputDirectory: directory, manifestFile, manifest };
253
318
  }
254
319
  export function createWowdumpCli(dependencies = {}) {
255
320
  const io = dependencies.io ?? { stdout: process.stdout, stderr: process.stderr };
256
321
  const env = dependencies.env ?? process.env;
257
322
  const cwd = resolve(dependencies.cwd ?? process.cwd());
323
+ const packageDist = installedDistDirectory();
258
324
  const home = resolveWowdumpHome(env);
259
325
  const broker = dependencies.broker ?? (process.platform === "win32" && !dependencies.sidecar
260
- ? (invocation => persistentWindowsBroker(home, cwd).request(invocation))
261
- : (invocation => defaultBroker(invocation, env, cwd, dependencies.sidecar)));
262
- const staticAnalysis = dependencies.staticAnalysis ?? runStaticAnalysis;
263
- const sidecar = dependencies.sidecar ?? defaultSidecar;
326
+ ? (invocation => persistentWindowsBroker(home, packageDist).request(invocation))
327
+ : (invocation => defaultBroker(invocation, env, packageDist, dependencies.sidecar)));
328
+ const baseSidecar = dependencies.sidecar ?? defaultSidecar;
329
+ const sidecar = async (file, args, input) => {
330
+ const worker = args.length === 1 && /\.m?js$/i.test(args[0] ?? "") ? args[0] : undefined;
331
+ if (worker && input !== undefined)
332
+ return runFridaWorker(worker, input, 120_000, broker);
333
+ return baseSidecar(file, args, input);
334
+ };
335
+ const profileEngine = new ProfileEngine();
336
+ const profileAdapter = new ReaderProfileAdapter(broker);
264
337
  const program = new Command()
265
338
  .name("wowdump")
266
339
  .description("Build-aware WoW native memory analysis CLI")
267
- .version("0.3.0")
340
+ .version(packageVersion())
268
341
  .showHelpAfterError()
269
342
  .configureOutput({ writeOut: value => io.stdout.write(value), writeErr: value => io.stderr.write(value) });
343
+ program.command("targets")
344
+ .description("Discover running WoW targets, executable paths, and build versions")
345
+ .option("--pid <pid>", "select one process ID")
346
+ .action(async (options) => {
347
+ const selectedPid = options.pid ? positiveInteger(options.pid, "pid") : undefined;
348
+ const targets = await discoverWowTargets(selectedPid, async (pid) => broker({ command: "modules", payload: { pid } }));
349
+ if (selectedPid !== undefined && targets.length === 0)
350
+ throw new CliError("TARGET_NOT_FOUND", `Wow.exe process ${selectedPid} was not found`);
351
+ writeJson(io, {
352
+ ok: targets.length > 0,
353
+ command: "targets",
354
+ selected: targets.length === 1 ? targets[0] : null,
355
+ requiresSelection: selectedPid === undefined && targets.length > 1,
356
+ targets
357
+ });
358
+ });
270
359
  program.command("target")
271
360
  .description("Show target, reader broker, profile, monitor, and Frida state")
272
361
  .option("--pid <pid>", "target process ID")
@@ -305,7 +394,8 @@ export function createWowdumpCli(dependencies = {}) {
305
394
  .option("--size <bytes>", "bounded byte count")
306
395
  .option("--request <json>", "complete read request as JSON")
307
396
  .action(async (options) => {
308
- const payload = options.request ? jsonRecord(options.request, "request") : {
397
+ const requested = options.request ? jsonRecord(options.request, "request") : undefined;
398
+ const payload = requested ?? {
309
399
  pid: positiveInteger(options.pid, "pid"),
310
400
  ...(options.build ? { buildKey: options.build } : {}),
311
401
  ...(options.profile ? { profileId: options.profile } : {}),
@@ -313,6 +403,20 @@ export function createWowdumpCli(dependencies = {}) {
313
403
  ...(options.address ? { address: options.address } : {}),
314
404
  ...(options.size ? { size: positiveInteger(options.size, "size") } : {})
315
405
  };
406
+ const pid = positiveInteger(String(payload.pid ?? options.pid), "pid");
407
+ const profileId = typeof payload.profileId === "string" ? payload.profileId : (typeof options.profile === "string" ? options.profile : undefined);
408
+ if (profileId && payload.address === undefined) {
409
+ const profileFilePath = profileFile(join(home, "profiles"), profileId);
410
+ const profile = jsonRecord(await readFile(profileFilePath, "utf8"), profileFilePath);
411
+ const result = await profileEngine.read({
412
+ pid,
413
+ buildKey: typeof payload.buildKey === "string" ? payload.buildKey : undefined,
414
+ profile,
415
+ fields: Array.isArray(payload.fields) ? payload.fields.map(String) : undefined
416
+ }, profileAdapter);
417
+ writeJson(io, { ...result, profileFile: profileFilePath });
418
+ return;
419
+ }
316
420
  writeJson(io, await broker({ command: "read", payload }));
317
421
  });
318
422
  const watch = memory.command("watch").description("Manage broker-owned memory monitors");
@@ -327,17 +431,21 @@ export function createWowdumpCli(dependencies = {}) {
327
431
  .option("--interval <ms>", "sample interval", "250")
328
432
  .option("--max-samples <count>", "sample limit", "1000")
329
433
  .option("--all-samples", "emit unchanged samples")
330
- .action(async (options) => writeJson(io, await broker({ command: "watch.start", payload: {
331
- pid: positiveInteger(options.pid, "pid"),
332
- ...(options.build ? { buildKey: options.build } : {}),
333
- ...(options.profile ? { profileId: options.profile } : {}),
334
- ...(options.field ? { fields: options.field } : {}),
335
- ...(options.address ? { address: options.address } : {}),
336
- ...(options.size ? { size: positiveInteger(options.size, "size") } : {}),
337
- intervalMs: positiveInteger(options.interval, "interval"),
338
- maxSamples: positiveInteger(options.maxSamples, "max-samples"),
339
- changeOnly: options.allSamples !== true
340
- } })));
434
+ .action(async (options) => {
435
+ if (options.profile)
436
+ throw new CliError("PROFILE_WATCH_UNSUPPORTED", "profile watch requires a fixed address; use repeated memory read calls for dynamic pointer fields");
437
+ writeJson(io, await broker({ command: "watch.start", payload: {
438
+ pid: positiveInteger(options.pid, "pid"),
439
+ ...(options.build ? { buildKey: options.build } : {}),
440
+ ...(options.profile ? { profileId: options.profile } : {}),
441
+ ...(options.field ? { fields: options.field } : {}),
442
+ ...(options.address ? { address: options.address } : {}),
443
+ ...(options.size ? { size: positiveInteger(options.size, "size") } : {}),
444
+ intervalMs: positiveInteger(options.interval, "interval"),
445
+ maxSamples: positiveInteger(options.maxSamples, "max-samples"),
446
+ changeOnly: options.allSamples !== true
447
+ } }));
448
+ });
341
449
  watch.command("poll")
342
450
  .description("Poll monitor events")
343
451
  .requiredOption("--id <watchId>", "monitor ID")
@@ -350,47 +458,39 @@ export function createWowdumpCli(dependencies = {}) {
350
458
  .description("Stop a monitor and release its resources")
351
459
  .requiredOption("--id <watchId>", "monitor ID")
352
460
  .action(async (options) => writeJson(io, await broker({ command: "watch.stop", payload: { watchId: options.id } })));
353
- const analyze = program.command("analyze").description("Run separate static, runtime-export, and verification stages");
354
- analyze.command("static")
355
- .description("Analyze a disk executable with Ghidra Headless")
461
+ const analyze = program.command("analyze").description("Run Frida evidence export and runtime-guided disassembly");
462
+ analyze.command("disassemble")
463
+ .description("Decode Frida text evidence and produce a Reader profile")
356
464
  .requiredOption("--exe <path>", "path to Wow.exe")
357
465
  .requiredOption("--build <buildKey>", "build key")
358
- .option("--build-key <buildKey>", "alias for --build")
359
- .option("--java <path>", "Java executable or JDK root")
360
- .option("--ghidra <path>", "analyzeHeadless executable or Ghidra root")
361
- .option("--output <directory>", "profile output directory")
362
- .option("--project-directory <directory>", "Ghidra project directory")
363
- .option("--project-name <name>", "Ghidra project name")
364
- .option("--script-directory <directory>", "Ghidra script directory")
365
- .option("--exporter-script <name>", "Ghidra post-script name")
366
- .option("--evidence <file>", "Ghidra exporter JSON output")
367
- .option("--timeout <seconds>", "analysis timeout", "1800")
368
- .option("--dry-run", "print the exact command without starting Ghidra")
369
- .action(async (options) => writeJson(io, await staticAnalysis({
370
- executable: options.exe,
371
- buildKey: options.build ?? options.buildKey,
372
- home,
373
- env,
374
- java: options.java,
375
- ghidra: options.ghidra,
376
- outputDirectory: options.output,
377
- projectDirectory: options.projectDirectory,
378
- projectName: options.projectName,
379
- scriptDirectory: options.scriptDirectory,
380
- exporterScript: options.exporterScript,
381
- evidenceFile: options.evidence,
382
- timeoutSeconds: positiveInteger(options.timeout, "timeout"),
466
+ .requiredOption("--runtime-export <file>", "runtime evidence JSON")
467
+ .option("--ida-evidence <file>", "JSON produced by an IDA Pro MCP analysis")
468
+ .option("--output <file>", "profile output JSON")
469
+ .option("--max-instructions <count>", "maximum decoded instructions", "256")
470
+ .option("--dry-run", "validate inputs without writing a profile")
471
+ .action(async (options) => writeJson(io, await runDisassembly({
472
+ exe: options.exe,
473
+ buildKey: options.build,
474
+ runtimeExport: options.runtimeExport,
475
+ ...(options.idaEvidence ? { idaEvidence: options.idaEvidence } : {}),
476
+ ...(options.output ? { output: options.output } : {}),
477
+ maxInstructions: positiveInteger(options.maxInstructions, "max-instructions"),
383
478
  dryRun: options.dryRun === true
384
479
  })));
385
- analyze.command("runtime-export")
386
- .description("Export runtime evidence through the isolated Frida sidecar")
480
+ analyze.command("runtime")
481
+ .description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
387
482
  .requiredOption("--pid <pid>", "target process ID")
388
483
  .requiredOption("--build <buildKey>", "build key")
389
484
  .option("--build-key <buildKey>", "alias for --build")
390
- .option("--kind <kind>", "text or providers", "providers")
485
+ .option("--kind <kind>", "dump or verify", "dump")
391
486
  .option("--worker <path>", "Frida worker entry")
392
- .option("--static-profile <path>", "static profile containing candidate RVAs")
393
- .option("--max-hooks <count>", "maximum hooks", "1")
487
+ .option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
488
+ .option("--output-dir <path>", "directory for dump manifest and section binaries")
489
+ .option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
490
+ .option("--max-total-bytes <bytes>", "total dump limit", "268435456")
491
+ .option("--chunk-size <bytes>", "dump chunk size", "1048576")
492
+ .option("--max-verify-bytes <bytes>", "maximum bytes read per candidate", "256")
493
+ .option("--max-hooks <count>", "reserved compatibility limit", "1")
394
494
  .option("--duration-ms <ms>", "maximum duration", "5000")
395
495
  .option("--max-events <count>", "maximum events", "100")
396
496
  .option("--cleanup-deadline-ms <ms>", "cleanup deadline", "2000")
@@ -404,54 +504,97 @@ export function createWowdumpCli(dependencies = {}) {
404
504
  durationMs: positiveInteger(options.durationMs, "duration-ms"),
405
505
  maxEvents: positiveInteger(options.maxEvents, "max-events"),
406
506
  cleanupDeadlineMs: positiveInteger(options.cleanupDeadlineMs, "cleanup-deadline-ms"),
507
+ maxSectionBytes: positiveInteger(options.maxSectionBytes, "max-section-bytes"),
508
+ maxTotalBytes: positiveInteger(options.maxTotalBytes, "max-total-bytes"),
509
+ chunkSize: positiveInteger(options.chunkSize, "chunk-size"),
510
+ maxVerifyBytes: positiveInteger(options.maxVerifyBytes, "max-verify-bytes"),
407
511
  confirm: options.confirm === true
408
512
  };
409
- if (!["text", "providers"].includes(normalized.kind))
410
- throw new CliError("ARGUMENT_INVALID", "kind must be text or providers");
513
+ if (!["dump", "verify"].includes(normalized.kind))
514
+ throw new CliError("ARGUMENT_INVALID", "kind must be dump or verify");
411
515
  requireRuntimeConfirmation(normalized);
412
- const worker = resolve(options.worker ?? env.WOWDUMP_FRIDA_WORKER ?? join(cwd, "dist", "frida-worker.js"));
516
+ if (normalized.kind === "verify" && !options.profile)
517
+ throw new CliError("PROFILE_REQUIRED", "verify requires --profile <file>");
518
+ const worker = resolve(options.worker ?? env.WOWDUMP_FRIDA_WORKER ?? join(packageDist, "frida-worker.js"));
413
519
  if (!existsSync(worker))
414
520
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
521
+ const profile = options.profile
522
+ ? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
523
+ : undefined;
415
524
  const request = {
416
- command: "runtime-export",
417
- ...selected(normalized, ["pid", "build", "kind", "maxHooks", "durationMs", "maxEvents", "cleanupDeadlineMs"]),
418
- ...(options.staticProfile ? { staticProfile: resolve(options.staticProfile) } : {})
525
+ command: "dynamic-script",
526
+ pid: normalized.pid,
527
+ build: normalized.build,
528
+ source: RUNTIME_EXPORT_SCRIPT,
529
+ exportName: "collect",
530
+ args: {
531
+ kind: normalized.kind,
532
+ maxHooks: normalized.maxHooks,
533
+ durationMs: normalized.durationMs,
534
+ maxEvents: normalized.maxEvents,
535
+ cleanupDeadlineMs: normalized.cleanupDeadlineMs,
536
+ maxSectionBytes: normalized.maxSectionBytes,
537
+ maxTotalBytes: normalized.maxTotalBytes,
538
+ chunkSize: normalized.chunkSize,
539
+ maxVerifyBytes: normalized.maxVerifyBytes,
540
+ ...(profile ? { profile } : {})
541
+ },
542
+ callArgs: [],
543
+ durationMs: normalized.durationMs
419
544
  };
420
545
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
421
546
  if (result.exitCode !== 0)
422
547
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
423
548
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
424
- writeJson(io, line ? JSON.parse(line) : { ok: true, command: "analyze.runtime-export", stdout: result.stdout });
549
+ const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
550
+ if (normalized.kind === "dump" && value.ok === true) {
551
+ const defaultDirectory = join(home, safeBuildDirectory(normalized.build), "runtime", `dump-${Date.now()}`);
552
+ const persisted = await persistRuntimeDump(value, options.outputDir ?? defaultDirectory);
553
+ writeJson(io, { ...persisted, command: "analyze.runtime.dump" });
554
+ }
555
+ else {
556
+ writeJson(io, { ...value, command: "analyze.runtime.verify" });
557
+ }
425
558
  });
426
- analyze.command("verify")
427
- .description("Compare static and runtime evidence without implicit promotion")
428
- .requiredOption("--static-profile <path>", "static profile JSON")
429
- .requiredOption("--runtime-export <path>", "runtime export JSON")
430
- .option("--output <path>", "optional verification report path")
559
+ analyze.command("dynamic")
560
+ .description("Run a caller-supplied GumJS script through Frida")
561
+ .requiredOption("--pid <pid>", "target process ID")
562
+ .requiredOption("--build <buildKey>", "build key")
563
+ .requiredOption("--script <path>", "GumJS script file")
564
+ .option("--export <name>", "rpc.exports function to call")
565
+ .option("--args <json>", "JSON object exposed as __WOWDUMP_INPUT__", "{}")
566
+ .option("--call-args <json>", "JSON array passed to the exported function", "[]")
567
+ .option("--duration-ms <ms>", "maximum script duration", "5000")
568
+ .option("--confirm", "confirm the exact Frida operation")
431
569
  .action(async (options) => {
432
- const staticFile = resolve(options.staticProfile);
433
- const runtimeFile = resolve(options.runtimeExport);
434
- const staticProfile = jsonRecord(await readFile(staticFile, "utf8"), staticFile);
435
- const runtimeExport = jsonRecord(await readFile(runtimeFile, "utf8"), runtimeFile);
436
- const checks = compareEvidence(staticProfile, runtimeExport);
437
- const report = {
438
- ok: checks.every(check => check.status !== "mismatch"),
439
- command: "analyze.verify",
440
- staticProfile: staticFile,
441
- runtimeExport: runtimeFile,
442
- checks,
443
- confidence: staticProfile.confidence ?? "unverified",
444
- promoted: false
570
+ const normalized = {
571
+ pid: positiveInteger(options.pid, "pid"),
572
+ build: options.build,
573
+ script: resolve(options.script),
574
+ ...(options.export ? { exportName: options.export } : {}),
575
+ args: jsonRecord(options.args, "args"),
576
+ callArgs: JSON.parse(options.callArgs),
577
+ durationMs: positiveInteger(options.durationMs, "duration-ms"),
578
+ confirm: options.confirm === true
445
579
  };
446
- if (options.output) {
447
- const { writeFile } = await import("node:fs/promises");
448
- await writeFile(resolve(options.output), `${JSON.stringify(report, null, 2)}\n`, "utf8");
449
- }
450
- writeJson(io, report);
580
+ if (!Array.isArray(normalized.callArgs))
581
+ throw new CliError("ARGUMENT_INVALID", "call-args must be a JSON array");
582
+ requireRuntimeConfirmation(normalized);
583
+ if (!existsSync(normalized.script))
584
+ throw new CliError("FRIDA_SCRIPT_NOT_FOUND", `Frida script was not found: ${normalized.script}`);
585
+ const worker = resolve(env.WOWDUMP_FRIDA_WORKER ?? join(packageDist, "frida-worker.js"));
586
+ if (!existsSync(worker))
587
+ throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
588
+ const request = { command: "dynamic-script", ...selected(normalized, ["pid", "build", "script", "exportName", "args", "callArgs", "durationMs"]) };
589
+ const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
590
+ if (result.exitCode !== 0)
591
+ throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
592
+ const line = result.stdout.split(/\r?\n/).find(value => value.trim());
593
+ writeJson(io, line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout });
451
594
  });
452
595
  program.command("init")
453
596
  .description("Initialize WOWDUMP_HOME without overwriting existing files")
454
- .option("--skip-toolchain-download", "only detect existing Java/Ghidra installations")
597
+ .option("--skip-toolchain-download", "kept for compatibility; no external toolchain is installed")
455
598
  .action(async (options) => {
456
599
  const installMissing = options.skipToolchainDownload !== true && env.WOWDUMP_SKIP_TOOLCHAIN_DOWNLOAD !== "1";
457
600
  const result = await bootstrapToolchain({ home, env, installMissing });
@@ -493,5 +636,7 @@ if (process.argv[1]) {
493
636
  invokedFile = pathToFileURL(invokedPath).href;
494
637
  }
495
638
  }
496
- if (invokedFile === import.meta.url)
639
+ if (invokedFile === import.meta.url) {
640
+ await initializeWowdumpHome();
497
641
  process.exitCode = await runWowdumpCli();
642
+ }