mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 16:57:19 +00:00
* refactor(analysis): centralize resource budgets and recovery facts * feat(analysis): expose ASAR and Ghidra runtime admission controls * feat(artifacts): expose reader admission and preserve limited recognition * feat(javascript): expose recoverable semantic precision budgets * feat(web): add recoverable source-map budgets and compact retained views * feat(web): make historical capture decoding budgets recoverable * fix(desktop): enforce selected view policies across capture and export * feat(runtime): make Frida and LLDB retention budgets selectable * feat(native): expose diagnostic budgets and preserve exhaustion causes * feat(managed): expose recoverable snapshot and representation budgets * feat(metadata): make Go and Rust inspection budgets recoverable * feat(wasm): expose representation and producer admissions * feat(evm): expose per-call worker resource budgets * feat(android): make resource and string-pool omissions recoverable * feat(producers): expose command and recorded-capture budgets * docs(analysis): record recovery contracts and integrated validation * fix(docs): link calibration oracle from published verification pages * fix(docs): restore desktop task anchors and receipt links * test(analysis): align fixtures with selectable budget contracts * fix(analysis): remove source-location projector dependency cycle * test(analysis): refresh budget recovery and verifier regressions * test(analysis): preserve format and publication recovery expectations * test(windows): align conformance with owned runtime admission * fix(desktop): preserve selected alias kinds in exported state * test(analysis): align provider fixtures with recoverable budgets * test(desktop): validate emitted alias metadata structurally * fix(ghidra): classify bounded Windows endpoint refusals * test(cli): assert selectable artifact admission recovery * fix(cli): preserve capture provenance in environment builds
411 lines
15 KiB
JavaScript
411 lines
15 KiB
JavaScript
import { AnalysisSettings } from "../dist/application/AnalysisSettings.js";
|
|
import { runtimeAnalysisConfig } from "../dist/config/runtimeAnalysisSettings.js";
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|
import { parseConfig } from "../dist/config/parseConfig.js";
|
|
import { projectAnalysisError } from "../dist/domain/analysisErrorProjection.js";
|
|
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
|
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
|
|
import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraReleasePolicy.js";
|
|
import { GhidraProvider } from "../dist/ghidra/GhidraProvider.js";
|
|
import { silentLogger } from "../dist/logger.js";
|
|
import {
|
|
OwnedCommandFailure,
|
|
runOwnedCommand,
|
|
} from "../dist/process/OwnedCommand.js";
|
|
import {
|
|
assertVerifierCleanupComplete,
|
|
completeVerifierRun,
|
|
createVerifierRun,
|
|
} from "./lib/verifier-run.mjs";
|
|
|
|
// Volatile straight-line work produces a natural native response above 1 MiB
|
|
// and a decompile above one second, without the old branch-heavy fixture.
|
|
// Admit the generated source before allocating its line array.
|
|
const LIMIT_SECONDS = 1;
|
|
const RECOVERY_SECONDS = 20;
|
|
const FIXTURE_SOURCE_BYTES = 1024 * 1024;
|
|
const FIXTURE_PREFIX = [
|
|
"volatile unsigned rea_state[128];",
|
|
"__attribute__((noinline, used)) unsigned rea_small(unsigned x) {",
|
|
" unsigned a = x + rea_state[0];",
|
|
" unsigned b = a ^ rea_state[1];",
|
|
" return b + (a * 3u);",
|
|
"}",
|
|
"__attribute__((noinline, used)) unsigned rea_slow(unsigned x) {",
|
|
" unsigned a = x;",
|
|
];
|
|
const FIXTURE_SUFFIX = [" return a;", "}", ""];
|
|
const MAX_STATEMENT_BYTES = Buffer.byteLength(
|
|
` a += rea_state[127] ^ (x + ${Number.MAX_SAFE_INTEGER}u); rea_state[127] = a;\n`,
|
|
);
|
|
const FIXTURE_FIXED_BYTES = Buffer.byteLength(
|
|
[...FIXTURE_PREFIX, ...FIXTURE_SUFFIX].join("\n"),
|
|
);
|
|
const MAX_STATEMENTS = Math.floor(
|
|
(FIXTURE_SOURCE_BYTES - FIXTURE_FIXED_BYTES) / MAX_STATEMENT_BYTES,
|
|
);
|
|
const STATEMENTS = Number(process.env.REA_DECOMPILE_TIMEOUT_STATEMENTS ?? 5400);
|
|
assert.ok(
|
|
Number.isSafeInteger(STATEMENTS) &&
|
|
STATEMENTS > 0 &&
|
|
STATEMENTS <= MAX_STATEMENTS,
|
|
`REA_DECOMPILE_TIMEOUT_STATEMENTS must be an integer from 1 to ${MAX_STATEMENTS} (generated source budget: ${FIXTURE_SOURCE_BYTES} bytes)`,
|
|
);
|
|
const fixtureSource = () => {
|
|
const lines = [...FIXTURE_PREFIX];
|
|
for (let index = 0; index < STATEMENTS; index += 1)
|
|
lines.push(
|
|
` a += rea_state[${index % 128}] ^ (x + ${index}u); rea_state[${(index * 7) % 128}] = a;`,
|
|
);
|
|
lines.push(...FIXTURE_SUFFIX);
|
|
const source = lines.join("\n");
|
|
assert.ok(Buffer.byteLength(source) <= FIXTURE_SOURCE_BYTES);
|
|
return source;
|
|
};
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const value = (result) => {
|
|
if (!result.ok) throw result.error;
|
|
return result.value;
|
|
};
|
|
const flowSummary = (flow) => ({
|
|
available: flow.available,
|
|
operations: flow.operations?.length,
|
|
largest_input_count: flow.operations?.reduce(
|
|
(largest, operation) => Math.max(largest, operation.inputs.length),
|
|
0,
|
|
),
|
|
def_use_edges: flow.def_use?.length,
|
|
parameter_uses: flow.parameter_uses?.length,
|
|
truncated: flow.truncated,
|
|
limits: flow.limits,
|
|
limits_reached: flow.limits_reached,
|
|
omitted_operations_lower_bound: flow.omitted_operations_lower_bound,
|
|
known_omitted_inputs: flow.known_omitted_inputs,
|
|
known_omitted_edges: flow.known_omitted_edges,
|
|
});
|
|
|
|
const run = createVerifierRun();
|
|
assert.ok(
|
|
process.platform === "linux" || process.platform === "darwin",
|
|
"This regression lane requires Linux or macOS",
|
|
);
|
|
const environment = {
|
|
...process.env,
|
|
GHIDRA_HEADLESS_MAXMEM:
|
|
process.env.GHIDRA_HEADLESS_MAXMEM || process.env.GHIDRA_MAXMEM || "1G",
|
|
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: String(RECOVERY_SECONDS),
|
|
};
|
|
const settings = new AnalysisSettings(environment);
|
|
const config = value(parseConfig(environment));
|
|
const installation = await inspectVerifierGhidraInstallation({
|
|
environment,
|
|
installDir: config.ghidraInstallDir,
|
|
javaHome: config.ghidraJavaHome,
|
|
});
|
|
assert.equal(installation.status, "available", JSON.stringify(installation));
|
|
assert.equal(installation.providerVersion, SUPPORTED_GHIDRA_VERSION);
|
|
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "rea-ghidra-decompile-timeout-"),
|
|
);
|
|
const began = performance.now();
|
|
const controller = new AbortController();
|
|
const outerDeadline = setTimeout(
|
|
() => controller.abort(new Error("Ghidra verifier exceeded 180000 ms")),
|
|
180_000,
|
|
);
|
|
outerDeadline.unref();
|
|
let cleaned = true;
|
|
let report;
|
|
try {
|
|
const sourcePath = join(workspace, "slow-decompile.c");
|
|
const source = fixtureSource();
|
|
await writeFile(sourcePath, source);
|
|
const targetPath = join(
|
|
workspace,
|
|
process.platform === "darwin"
|
|
? "slow-decompile.dylib"
|
|
: "slow-decompile.so",
|
|
);
|
|
const compiler = {
|
|
command: process.env.REA_CC ?? "cc",
|
|
arguments: [
|
|
...(process.platform === "darwin" ? ["-dynamiclib"] : ["-shared"]),
|
|
"-fPIC",
|
|
"-O0",
|
|
sourcePath,
|
|
"-o",
|
|
targetPath,
|
|
],
|
|
};
|
|
cleaned = false;
|
|
try {
|
|
await runOwnedCommand(
|
|
{ ...compiler, runId: run.run_id, expectedCommand: null },
|
|
{
|
|
timeoutMs: 30_000,
|
|
diagnosticBytes: 64 * 1024,
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
cleaned = true;
|
|
} catch (cause) {
|
|
cleaned =
|
|
cause instanceof OwnedCommandFailure && cause.cleanupOwner === undefined;
|
|
throw cause;
|
|
}
|
|
const targetDigest = digest(await readFile(targetPath));
|
|
let bridgeClient;
|
|
const provider = new GhidraProvider(
|
|
runtimeAnalysisConfig(config, settings.read),
|
|
silentLogger,
|
|
environment,
|
|
undefined,
|
|
(options) => {
|
|
assert.equal(options.runId, undefined);
|
|
bridgeClient = new GhidraClient({
|
|
...options,
|
|
transport: "authenticated-loopback-tcp",
|
|
});
|
|
return bridgeClient;
|
|
},
|
|
);
|
|
const target = value(await parseBinaryTarget(targetPath));
|
|
const profile = value(await provider.resolveAnalysisProfile(target));
|
|
assert.ok(profile.profile);
|
|
const client = provider.createClient(target, profile.profile);
|
|
const execute = (operation, input) =>
|
|
settings.run(() =>
|
|
client.execute(operation, input, { signal: controller.signal }),
|
|
);
|
|
const update = (changes) =>
|
|
value(
|
|
settings.update({
|
|
changes: changes.map(([setting, selected]) => ({
|
|
setting,
|
|
action: "set",
|
|
value: selected,
|
|
})),
|
|
}),
|
|
);
|
|
const observed = { stages: [] };
|
|
const record = (stage, facts) => observed.stages.push({ stage, ...facts });
|
|
const failures = [];
|
|
cleaned = false;
|
|
try {
|
|
const listed = value(await execute("list_procedures", {}));
|
|
const entry = (name) => {
|
|
const found = listed.result.find(
|
|
(item) => item.value === name || item.value === `_${name}`,
|
|
);
|
|
assert.ok(found, `Missing fixture function ${name}`);
|
|
return found.address;
|
|
};
|
|
const smallEntry = entry("rea_small");
|
|
const slowEntry = entry("rea_slow");
|
|
const initial = value(
|
|
await bridgeClient.ping({ signal: controller.signal }),
|
|
);
|
|
assert.equal(initial.target.sha256, targetDigest);
|
|
observed.initial_session_run_id = initial.run_id;
|
|
const flow = async () =>
|
|
value(await execute("analyze_function", { procedure: smallEntry })).result
|
|
.native_value_flow;
|
|
|
|
update([["ghidra.value_flow_max_operations", 1]]);
|
|
const operationsLow = await flow();
|
|
assert.equal(operationsLow.operations.length, 1);
|
|
assert.ok(operationsLow.limits_reached.includes("max_operations"));
|
|
record("operations_low", flowSummary(operationsLow));
|
|
update([["ghidra.value_flow_max_operations", 3000]]);
|
|
const complete = await flow();
|
|
assert.equal(complete.truncated, false);
|
|
assert.ok(complete.operations.length > 2);
|
|
record("operations_recovered", flowSummary(complete));
|
|
|
|
update([["ghidra.value_flow_max_inputs_per_operation", 1]]);
|
|
const inputsLow = await flow();
|
|
assert.ok(inputsLow.known_omitted_inputs > 0);
|
|
assert.ok(inputsLow.limits_reached.includes("max_inputs_per_operation"));
|
|
record("inputs_low", flowSummary(inputsLow));
|
|
update([["ghidra.value_flow_max_inputs_per_operation", 64]]);
|
|
const inputsRecovered = await flow();
|
|
assert.equal(inputsRecovered.truncated, false);
|
|
record("inputs_recovered", flowSummary(inputsRecovered));
|
|
|
|
update([["ghidra.value_flow_max_edges", 1]]);
|
|
const edgesLow = await flow();
|
|
assert.ok(edgesLow.known_omitted_edges > 0);
|
|
assert.ok(edgesLow.limits_reached.includes("max_edges"));
|
|
record("edges_low", flowSummary(edgesLow));
|
|
update([["ghidra.value_flow_max_edges", 12000]]);
|
|
const edgesRecovered = await flow();
|
|
assert.equal(edgesRecovered.truncated, false);
|
|
assert.ok(edgesRecovered.def_use.length > 1);
|
|
record("edges_recovered", flowSummary(edgesRecovered));
|
|
|
|
update([["ghidra.value_flow_max_operations", 2]]);
|
|
const lowered = await flow();
|
|
assert.equal(lowered.operations.length, 2);
|
|
assert.ok(lowered.limits_reached.includes("max_operations"));
|
|
record("operations_lowered", flowSummary(lowered));
|
|
update([
|
|
["ghidra.value_flow_max_operations", 3000],
|
|
["ghidra.decompile_max_payload_mib", 1],
|
|
]);
|
|
|
|
let started = performance.now();
|
|
const payloadLow = await execute("procedure_pseudo_code", {
|
|
procedure: slowEntry,
|
|
});
|
|
assert.equal(
|
|
payloadLow.ok,
|
|
false,
|
|
"Fixture did not exhaust the 1 MiB payload allowance; calibrate REA_DECOMPILE_TIMEOUT_STATEMENTS rather than reporting a provider failure.",
|
|
);
|
|
const payloadError = projectAnalysisError(payloadLow.error);
|
|
assert.ok(
|
|
payloadError.message.includes("Response buffer size exceeded"),
|
|
payloadError.message,
|
|
);
|
|
assert.ok(
|
|
payloadError.message.includes("ghidra.decompile_max_payload_mib"),
|
|
);
|
|
record("payload_1_mib", {
|
|
seconds: (performance.now() - started) / 1000,
|
|
code: payloadError.code,
|
|
message: payloadError.message,
|
|
});
|
|
update([["ghidra.decompile_max_payload_mib", 64]]);
|
|
started = performance.now();
|
|
const payloadRecovered = value(
|
|
await execute("procedure_pseudo_code", { procedure: slowEntry }),
|
|
);
|
|
assert.ok(String(payloadRecovered.result).includes("rea_state"));
|
|
record("payload_64_mib_recovered", {
|
|
seconds: (performance.now() - started) / 1000,
|
|
pseudocode_bytes: Buffer.byteLength(String(payloadRecovered.result)),
|
|
});
|
|
|
|
update([["ghidra.decompile_timeout_seconds", LIMIT_SECONDS]]);
|
|
const limitText = `exceeded the ${LIMIT_SECONDS}-second configured decompilation limit`;
|
|
started = performance.now();
|
|
const degraded = value(
|
|
await execute("analyze_function", { procedure: slowEntry }),
|
|
);
|
|
const dossier = degraded.result;
|
|
assert.equal(
|
|
dossier.facet_availability.pseudocode.available,
|
|
false,
|
|
"Fixture completed within the one-second native deadline; calibrate REA_DECOMPILE_TIMEOUT_STATEMENTS rather than reporting a provider failure.",
|
|
);
|
|
assert.ok(dossier.facet_availability.pseudocode.reason.includes(limitText));
|
|
assert.equal(dossier.native_api.available, false);
|
|
assert.equal(dossier.native_value_flow.available, false);
|
|
assert.ok(dossier.assembly.length > STATEMENTS);
|
|
assert.ok(dossier.basic_blocks.length > 0);
|
|
assert.equal(degraded.cachePolicy, "live");
|
|
record("deadline_partial_dossier", {
|
|
seconds: (performance.now() - started) / 1000,
|
|
pseudocode: dossier.facet_availability.pseudocode,
|
|
assembly_lines: dossier.assembly.length,
|
|
basic_blocks: dossier.basic_blocks.length,
|
|
cache_policy: degraded.cachePolicy,
|
|
});
|
|
|
|
started = performance.now();
|
|
const timedOut = await execute("procedure_pseudo_code", {
|
|
procedure: slowEntry,
|
|
});
|
|
assert.equal(timedOut.ok, false);
|
|
assert.equal(timedOut.error._tag, "AnalysisTimeoutError");
|
|
const timeout = projectAnalysisError(timedOut.error);
|
|
assert.equal(timeout.code, "provider_timeout");
|
|
assert.equal(timeout.retryable, true);
|
|
assert.equal(timeout.details.timeout_ms, 1000);
|
|
assert.equal(
|
|
timeout.details.limit.setting,
|
|
"ghidra.decompile_timeout_seconds",
|
|
);
|
|
assert.equal(timeout.details.limit.scope, "rea-runtime");
|
|
assert.ok(timeout.message.includes(limitText), timeout.message);
|
|
record("deadline_pseudocode", {
|
|
seconds: (performance.now() - started) / 1000,
|
|
code: timeout.code,
|
|
message: timeout.message,
|
|
limit: timeout.details.limit,
|
|
remediation: timeout.remediation,
|
|
});
|
|
|
|
update([
|
|
["ghidra.decompile_timeout_seconds", RECOVERY_SECONDS],
|
|
["ghidra.decompile_max_payload_mib", null],
|
|
]);
|
|
started = performance.now();
|
|
const recovered = value(
|
|
await execute("procedure_pseudo_code", { procedure: slowEntry }),
|
|
);
|
|
assert.ok(String(recovered.result).includes("rea_state"));
|
|
record("deadline_recovered_payload_null", {
|
|
seconds: (performance.now() - started) / 1000,
|
|
selected_timeout_seconds: RECOVERY_SECONDS,
|
|
selected_payload_mib: null,
|
|
pseudocode_bytes: Buffer.byteLength(String(recovered.result)),
|
|
});
|
|
const small = value(
|
|
await execute("procedure_pseudo_code", { procedure: smallEntry }),
|
|
);
|
|
assert.ok(String(small.result).includes("rea_state"));
|
|
const final = value(await bridgeClient.ping({ signal: controller.signal }));
|
|
assert.equal(final.run_id, initial.run_id);
|
|
assert.equal(final.target.sha256, targetDigest);
|
|
observed.final_session_run_id = final.run_id;
|
|
observed.same_session_next_decompile = "succeeded";
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
}
|
|
const closed = await client.close();
|
|
if (!closed.ok) failures.push(closed.error);
|
|
if (failures.length > 0)
|
|
throw new AggregateError(
|
|
failures,
|
|
"Ghidra runtime budget verification failed",
|
|
);
|
|
assert.equal(digest(await readFile(targetPath)), targetDigest);
|
|
const verifierRun = await completeVerifierRun(run);
|
|
assertVerifierCleanupComplete(verifierRun.process_lineage);
|
|
cleaned = true;
|
|
report = {
|
|
status: "passed",
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
node: process.version,
|
|
fixture: {
|
|
kind: "volatile-straight-line",
|
|
statements: STATEMENTS,
|
|
source_bytes: Buffer.byteLength(source),
|
|
},
|
|
target_sha256: targetDigest,
|
|
source_sha256: digest(Buffer.from(source)),
|
|
compiler,
|
|
provider: profile.profile.provider,
|
|
analysis_profile_digest: profile.profile.digest,
|
|
configured_jvm_max_heap: environment.GHIDRA_HEADLESS_MAXMEM,
|
|
elapsed_seconds: (performance.now() - began) / 1000,
|
|
...observed,
|
|
verifier_run: verifierRun,
|
|
scope:
|
|
"Real Ghidra analysis of generated Mach-O/ELF through authenticated loopback: independent value-flow budgets, lowered admission, native payload exhaustion and recovery, natural decompile timeout with listing-backed partial facts, and deadline recovery on the same Program. No target execution. Windows and public CLI/MCP transport recovery are unverified. Timings are observations, not performance comparisons; CPU, peak process-family memory and agent tokens are unmeasured.",
|
|
};
|
|
} finally {
|
|
clearTimeout(outerDeadline);
|
|
if (cleaned) await rm(workspace, { recursive: true, force: true });
|
|
}
|
|
console.log(JSON.stringify({ ...report, cleanup: "complete" }));
|