Add opt-in summaries and stable selected-field pages for native inventories through the shared CLI/MCP Evidence layer. Preserve complete source access, row identity, provenance, string qualifications and unknown discovery coverage. Record real true/jq workflows and portable old-Evidence checks, including tokenizer proxy reductions, additional call/schema costs and observed latency/memory regressions. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
/** Compare complete delivery with selected native inventory views on real Ghidra. */
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { createWriteStream } from "node:fs";
|
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { finished } from "node:stream/promises";
|
|
import { isDeepStrictEqual, parseArgs } from "node:util";
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
|
|
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
|
|
|
const { values } = parseArgs({
|
|
options: {
|
|
...Object.fromEntries(
|
|
["runtime", "target", "output", "ghidra", "java", "mode", "control"].map(
|
|
(name) => [name, { type: "string" }],
|
|
),
|
|
),
|
|
"verify-pages": { type: "boolean", default: false },
|
|
},
|
|
});
|
|
for (const name of ["runtime", "target", "output", "ghidra", "java"])
|
|
assert.ok(values[name] && values[name] === resolve(values[name]), name);
|
|
assert.ok(["complete", "summary"].includes(values.mode));
|
|
assert.ok(!values["verify-pages"] || values.mode === "summary");
|
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
const output = values.output;
|
|
await mkdir(output, { mode: 0o700 });
|
|
const targetBytes = await readFile(values.target);
|
|
const home = await mkdtemp(join(tmpdir(), "rea-native-inventory-"));
|
|
const client = new Client({ name: "native-inventory-views", version: "1" });
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
|
env: {
|
|
HOME: home,
|
|
PATH: process.env.PATH,
|
|
NODE_OPTIONS: "--max-old-space-size=2048",
|
|
GHIDRA_INSTALL_DIR: values.ghidra,
|
|
JAVA_HOME: values.java,
|
|
GHIDRA_HEADLESS_MAXMEM: "768M",
|
|
JAVA_TOOL_OPTIONS: "-XX:ActiveProcessorCount=1",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
|
|
REA_LOG_LEVEL: "silent",
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
const serverLog = createWriteStream(join(output, "server.log"));
|
|
const serverLogFinished = finished(serverLog);
|
|
serverLogFinished.catch(() => {});
|
|
transport.stderr?.pipe(serverLog);
|
|
const report = {
|
|
artifact: {
|
|
path: values.target,
|
|
bytes: targetBytes.length,
|
|
sha256: hash(targetBytes),
|
|
},
|
|
mode: values.mode,
|
|
verify_pages: values["verify-pages"],
|
|
question:
|
|
"Report retained row counts and the first four address/value pairs for procedures, names, strings, and literal main procedure/string matches.",
|
|
calls: [],
|
|
tasks: [],
|
|
local_recoveries: [],
|
|
};
|
|
const transcript = [];
|
|
const call = createRetainedQueryCaller(client, (receipt, event) => {
|
|
report.calls.push(receipt);
|
|
transcript.push(event);
|
|
});
|
|
const result = async (name, input, phase) => {
|
|
const reply = await call(name, input, phase ?? name);
|
|
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
|
assert.ok(reply.structuredContent);
|
|
return reply.structuredContent;
|
|
};
|
|
const operations = [
|
|
"list_procedures",
|
|
"list_names",
|
|
"list_strings",
|
|
"search_procedures",
|
|
"search_strings",
|
|
];
|
|
const selectedRows = (rows) =>
|
|
rows.slice(0, 4).map(({ address, value }) => ({ address, value }));
|
|
const sourceReferences = [];
|
|
const started = performance.now();
|
|
let opened = false;
|
|
try {
|
|
await client.connect(transport);
|
|
const discovery = performance.now();
|
|
const catalog = await client.listTools();
|
|
assert.equal(catalog.nextCursor, undefined);
|
|
report.catalog = {
|
|
elapsed_ms: performance.now() - discovery,
|
|
tool_count: catalog.tools.length,
|
|
bytes: Buffer.byteLength(JSON.stringify(catalog)),
|
|
sha256: hash(JSON.stringify(catalog)),
|
|
};
|
|
await writeFile(join(output, "catalog.json"), JSON.stringify(catalog));
|
|
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
|
const original = client.callTool.bind(client);
|
|
client.callTool = (input, options) =>
|
|
original(input, {
|
|
...options,
|
|
timeout: 600000,
|
|
toolDefinition: definitions.get(input.name),
|
|
});
|
|
await result("open_binary", { path: values.target, provider_id: "ghidra" });
|
|
opened = true;
|
|
for (const phase of ["first", "warm"]) {
|
|
const taskStarted = performance.now();
|
|
const facts = [];
|
|
for (const operation of operations) {
|
|
const parameters = operation.startsWith("search_")
|
|
? { pattern: "main", mode: "literal", case_sensitive: false }
|
|
: {};
|
|
const input =
|
|
values.mode === "summary"
|
|
? { ...parameters, detail: "summary" }
|
|
: parameters;
|
|
const reply = await call(operation, input, `${phase}:${operation}`);
|
|
let evidence;
|
|
if (reply.isError) {
|
|
const text = reply.content.find((item) => item.type === "text");
|
|
assert.ok(text);
|
|
const error = JSON.parse(text.text).error;
|
|
assert.equal(values.mode, "complete");
|
|
assert.equal(error.code, "resource_constraint");
|
|
const reference = error.details?.reported_limits?.evidence_reference;
|
|
assert.equal(error.details?.reported_limits?.boundary, "mcp-response");
|
|
assert.equal(reference?.kind, "retained-evidence");
|
|
const recovery = { phase: `${phase}:${operation}`, status: "started" };
|
|
report.local_recoveries.push(recovery);
|
|
const path = join(
|
|
output,
|
|
`recovery-${report.local_recoveries.length}.json`,
|
|
);
|
|
await result(
|
|
"export_evidence_bundle",
|
|
{ path },
|
|
`${phase}:${operation}:recovery-export`,
|
|
);
|
|
const recoveryStarted = performance.now();
|
|
try {
|
|
const bytes = await readFile(path);
|
|
const bundle = JSON.parse(bytes);
|
|
evidence = bundle.records.find(
|
|
(record) => record.evidence_id === reference.evidence_id,
|
|
);
|
|
assert.ok(evidence);
|
|
assert.equal(evidence.operation, operation);
|
|
assert.ok(Array.isArray(evidence.normalized_result));
|
|
// This is local JSON field selection after the documented export recovery,
|
|
// not another provider query or an MCP view available in the baseline.
|
|
const projection = {
|
|
evidence_id: evidence.evidence_id,
|
|
operation,
|
|
row_count: evidence.normalized_result.length,
|
|
rows: selectedRows(evidence.normalized_result),
|
|
provider: evidence.provider,
|
|
parameters: evidence.parameters,
|
|
limitations: evidence.limitations,
|
|
};
|
|
recovery.bundle_bytes_read = bytes.length;
|
|
recovery.projection_bytes = Buffer.byteLength(
|
|
JSON.stringify(projection),
|
|
);
|
|
recovery.status = "passed";
|
|
transcript.push({
|
|
name: "local-bundle-projection",
|
|
phase: recovery.phase,
|
|
input: {
|
|
path,
|
|
evidence_id: reference.evidence_id,
|
|
offset: 0,
|
|
limit: 4,
|
|
fields: ["address", "value"],
|
|
},
|
|
projection,
|
|
});
|
|
} finally {
|
|
recovery.elapsed_ms = performance.now() - recoveryStarted;
|
|
}
|
|
} else evidence = reply.structuredContent;
|
|
assert.ok(evidence);
|
|
assert.equal(evidence.subject.digest.sha256, report.artifact.sha256);
|
|
let count, rows, provider, query, parent;
|
|
if (values.mode === "complete") {
|
|
assert.equal(evidence.operation, operation);
|
|
assert.equal(evidence.provider.id, "ghidra");
|
|
assert.ok(Array.isArray(evidence.normalized_result));
|
|
count = evidence.normalized_result.length;
|
|
rows = selectedRows(evidence.normalized_result);
|
|
provider = evidence.provider;
|
|
query = evidence.parameters;
|
|
parent = evidence.evidence_id;
|
|
} else {
|
|
assert.equal(evidence.operation, "inspect_analysis_view");
|
|
const summary = evidence.normalized_result;
|
|
assert.equal(summary.kind, "summary");
|
|
assert.equal(summary.parent_operation, operation);
|
|
assert.equal(summary.summary.discovery_coverage, "unknown");
|
|
assert.equal(summary.source_provider.id, "ghidra");
|
|
count = summary.summary.row_count;
|
|
provider = summary.source_provider;
|
|
query = summary.source_parameters;
|
|
parent = summary.parent_evidence_id;
|
|
assert.deepEqual(evidence.evidence_links, [parent]);
|
|
rows = [];
|
|
if (count > 0) {
|
|
const pageEvidence = await result(
|
|
"inspect_analysis_view",
|
|
{
|
|
source: { kind: "retained-evidence", evidence_id: parent },
|
|
view: {
|
|
kind: "native-inventory",
|
|
offset: 0,
|
|
limit: 4,
|
|
fields: ["value"],
|
|
},
|
|
},
|
|
`${phase}:${operation}:page`,
|
|
);
|
|
const page = pageEvidence.normalized_result;
|
|
assert.equal(page.kind, "native-inventory");
|
|
assert.equal(page.parent_evidence_id, parent);
|
|
assert.equal(page.coverage.total, count);
|
|
assert.equal(page.rows.length, Math.min(4, count));
|
|
for (const [index, row] of page.rows.entries())
|
|
assert.equal(row.source_index, index);
|
|
rows = page.rows.map(({ record: { address, value } }) => ({
|
|
address,
|
|
value,
|
|
}));
|
|
}
|
|
}
|
|
assert.ok(Number.isSafeInteger(count) && count >= 0);
|
|
if (operation.startsWith("list_")) assert.ok(count > 0, operation);
|
|
assert.equal(provider.version, "12.1.4");
|
|
assert.equal(Object.hasOwn(query, "detail"), false);
|
|
assert.equal(rows.length, Math.min(4, count));
|
|
facts.push({
|
|
operation,
|
|
row_count: count,
|
|
rows,
|
|
provider,
|
|
parameters: query,
|
|
limitations: [...new Set(evidence.limitations)].sort(),
|
|
});
|
|
sourceReferences.push({ phase, operation, parent });
|
|
}
|
|
report.tasks.push({
|
|
phase,
|
|
elapsed_ms: performance.now() - taskStarted,
|
|
facts,
|
|
});
|
|
}
|
|
assert.deepEqual(report.tasks[0].facts, report.tasks[1].facts);
|
|
const taskFacts = report.tasks.map(({ phase, facts }) => ({ phase, facts }));
|
|
await writeFile(join(output, "facts.json"), JSON.stringify(taskFacts));
|
|
report.facts_sha256 = hash(JSON.stringify(taskFacts));
|
|
const bundlePath = join(output, "evidence.json");
|
|
await result("export_evidence_bundle", { path: bundlePath });
|
|
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
|
const sources = sourceReferences.map(({ phase, operation, parent }) => {
|
|
const record = bundle.records.find(
|
|
(record) => record.evidence_id === parent,
|
|
);
|
|
assert.ok(record);
|
|
assert.equal(record.operation, operation);
|
|
assert.equal(record.subject.digest.sha256, report.artifact.sha256);
|
|
const task = report.tasks
|
|
.find((task) => task.phase === phase)
|
|
.facts.find((fact) => fact.operation === operation);
|
|
assert.equal(record.normalized_result.length, task.row_count);
|
|
assert.deepEqual(selectedRows(record.normalized_result), task.rows);
|
|
assert.deepEqual(record.parameters, task.parameters);
|
|
assert.deepEqual(record.provider, task.provider);
|
|
return { phase, operation, record };
|
|
});
|
|
await writeFile(join(output, "sources.json"), JSON.stringify(sources));
|
|
report.sources_sha256 = hash(JSON.stringify(sources));
|
|
if (values.control) {
|
|
assert.deepEqual(
|
|
taskFacts,
|
|
JSON.parse(await readFile(join(values.control, "facts.json"), "utf8")),
|
|
);
|
|
const control = JSON.parse(
|
|
await readFile(join(values.control, "sources.json"), "utf8"),
|
|
);
|
|
// Assertion formatting of a large mismatch can itself exhaust the experiment
|
|
// scope. Retain both files and report a bounded failure without weakening it.
|
|
assert.ok(
|
|
isDeepStrictEqual(sources, control),
|
|
`Full source Evidence differs from control; inspect retained sources.json files (${report.sources_sha256} vs ${hash(JSON.stringify(control))}).`,
|
|
);
|
|
}
|
|
if (values["verify-pages"]) {
|
|
for (const { operation, parent } of sourceReferences.filter(
|
|
(source) => source.phase === "first",
|
|
)) {
|
|
const source = sources.find(
|
|
(source) => source.phase === "first" && source.operation === operation,
|
|
).record;
|
|
const all = [];
|
|
let offset = 0;
|
|
do {
|
|
const evidence = await result(
|
|
"inspect_analysis_view",
|
|
{
|
|
source: { kind: "retained-evidence", evidence_id: parent },
|
|
view: { kind: "native-inventory", offset, limit: 128 },
|
|
},
|
|
`verification:${operation}:${offset}`,
|
|
);
|
|
const page = evidence.normalized_result;
|
|
assert.equal(page.kind, "native-inventory");
|
|
assert.equal(
|
|
page.rows.length,
|
|
Math.min(128, source.normalized_result.length - offset),
|
|
);
|
|
for (const [index, row] of page.rows.entries())
|
|
assert.equal(row.source_index, offset + index);
|
|
all.push(...page.rows.map((row) => row.record));
|
|
assert.equal(
|
|
page.coverage.next_offset,
|
|
offset + page.rows.length >= source.normalized_result.length
|
|
? null
|
|
: offset + page.rows.length,
|
|
);
|
|
offset = page.coverage.next_offset;
|
|
} while (offset !== null);
|
|
assert.deepEqual(all, source.normalized_result);
|
|
}
|
|
}
|
|
assert.equal(hash(await readFile(values.target)), report.artifact.sha256);
|
|
report.status = "passed";
|
|
} catch (cause) {
|
|
report.status = "failed";
|
|
report.failure = String(cause?.stack ?? cause);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (opened)
|
|
try {
|
|
await result("close_binary", {});
|
|
} catch (cause) {
|
|
report.status = "failed";
|
|
report.close_failure = String(cause?.stack ?? cause);
|
|
}
|
|
report.elapsed_before_cleanup_ms = performance.now() - started;
|
|
try {
|
|
await writeFile(
|
|
join(output, "transcript.json"),
|
|
JSON.stringify(transcript),
|
|
);
|
|
} catch (cause) {
|
|
report.status = "failed";
|
|
report.transcript_failure = String(cause);
|
|
}
|
|
const finalized = await finalizeRetainedQueryReceipt({
|
|
report,
|
|
client,
|
|
transport,
|
|
account: home,
|
|
serverLog,
|
|
serverLogFinished,
|
|
output,
|
|
});
|
|
if (report.status !== "passed" || !finalized) process.exitCode = 1;
|
|
}
|