ci(ghidra): add Windows P0 acceptance and real-engine lanes
Add a hosted Windows x64 lane for named host capabilities, portable provider tests, and packed CLI/MCP verification. Add a default-branch-only repository-dispatch workflow for a controlled self-hosted Ghidra 12.1.2 runner, backed by a deterministic source-owned native PE fixture and a verifier that exercises all 18 admitted operations, target digest linkage, authenticated loopback transport, and complete runtime cleanup.
This commit is contained in:
parent
bc0fb45f16
commit
0b3551076d
|
|
@ -131,6 +131,48 @@ jobs:
|
|||
- run: npm run rebuild:native
|
||||
- run: npm run verify:package
|
||||
|
||||
windows-curated:
|
||||
name: Windows Ghidra P0 and package lane
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run typecheck
|
||||
- name: Record named Windows host capabilities
|
||||
run: node scripts/windows-capabilities.mjs | Out-File -Encoding utf8NoBOM windows-capabilities.json
|
||||
- name: Validate Windows host capability report
|
||||
run: >-
|
||||
node -e "const report = JSON.parse(require('node:fs').readFileSync('windows-capabilities.json', 'utf8'));
|
||||
if (report.platform !== 'win32' || report.architecture !== 'x64')
|
||||
throw new Error('expected a Windows x64 capability report');"
|
||||
- name: Run curated Windows P0 tests
|
||||
run: >-
|
||||
npx vitest run --coverage.enabled=false
|
||||
tests/binaryTarget.test.ts
|
||||
tests/windowsCapabilities.test.ts
|
||||
tests/ghidraWindowsFixture.test.ts
|
||||
tests/ghidraInstallation.test.ts
|
||||
tests/ghidraProvider.test.ts
|
||||
tests/ghidraTransport.test.ts
|
||||
tests/ghidraTargetSnapshot.test.ts
|
||||
tests/ghidraClient.test.ts
|
||||
tests/ghidraLauncher.test.ts
|
||||
tests/processOwnership.test.ts
|
||||
tests/realGhidraWindowsWorkflow.test.ts
|
||||
- run: npm run verify:package:windows
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: windows-capabilities
|
||||
path: windows-capabilities.json
|
||||
retention-days: 30
|
||||
|
||||
docs:
|
||||
name: Docs Generation
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
name: Real Ghidra Windows E2E
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [real-ghidra-windows]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Real Ghidra 12.1.2 Windows x64 PE proof
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: real-ghidra-windows
|
||||
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Verify all admitted Windows Ghidra operations
|
||||
env:
|
||||
REA_ANALYSIS_PROVIDER: ghidra
|
||||
run: npm run verify:ghidra:windows | Tee-Object -FilePath windows-ghidra-proof.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: windows-ghidra-proof
|
||||
path: windows-ghidra-proof.log
|
||||
retention-days: 30
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||
"verify:ghidra": "npm run build && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:windows": "npm run build && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
|
||||
"verify:browser": "npm run build && node scripts/verify-real-browser.mjs",
|
||||
"verify:javascript-runtime": "npm run build && node scripts/verify-local-javascript-runtime.mjs",
|
||||
"verify:application-workflows": "npm run build && node scripts/verify-local-application-workflows.mjs",
|
||||
|
|
@ -83,8 +84,10 @@
|
|||
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
|
||||
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
|
||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||
"verify:package": "npm run build && node scripts/verify-package.mjs",
|
||||
"verify:package:windows": "npm run build && node scripts/verify-windows-package.mjs",
|
||||
"probe:windows-capabilities": "npm run build && node scripts/windows-capabilities.mjs",
|
||||
"verify:published": "node scripts/verify-published-package.mjs",
|
||||
"config:print": "node scripts/print-mcp-config.mjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const output = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe");
|
||||
const bytes = Buffer.alloc(1024);
|
||||
const peOffset = 0x80;
|
||||
const optionalHeader = peOffset + 24;
|
||||
const sectionTable = optionalHeader + 0xf0;
|
||||
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(peOffset, 0x3c);
|
||||
bytes.write("PE\0\0", peOffset, "binary");
|
||||
bytes.writeUInt16LE(0x8664, peOffset + 4);
|
||||
bytes.writeUInt16LE(1, peOffset + 6);
|
||||
bytes.writeUInt16LE(0xf0, peOffset + 20);
|
||||
bytes.writeUInt16LE(0x22, peOffset + 22);
|
||||
|
||||
bytes.writeUInt16LE(0x20b, optionalHeader);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 4);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 16);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
|
||||
bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 32);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 36);
|
||||
bytes.writeUInt16LE(6, optionalHeader + 40);
|
||||
bytes.writeUInt16LE(6, optionalHeader + 48);
|
||||
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 60);
|
||||
bytes.writeUInt16LE(3, optionalHeader + 68);
|
||||
bytes.writeUInt16LE(0x8160, optionalHeader + 70);
|
||||
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72);
|
||||
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80);
|
||||
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88);
|
||||
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96);
|
||||
bytes.writeUInt32LE(16, optionalHeader + 108);
|
||||
|
||||
bytes.write(".text", sectionTable, "ascii");
|
||||
bytes.writeUInt32LE(0x16, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
||||
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
|
||||
|
||||
// entry calls the second function, returns zero; second returns 42.
|
||||
bytes.set([0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200);
|
||||
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, bytes);
|
||||
process.stdout.write(`${output}\n`);
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { access } from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
|
||||
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
||||
import { GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS } from "../dist/ghidra/GhidraDefaults.js";
|
||||
import {
|
||||
GHIDRA_FUNCTION_OPERATIONS,
|
||||
parseGhidraFunctionInput,
|
||||
parseGhidraFunctionResult,
|
||||
} from "../dist/ghidra/GhidraFunctionValues.js";
|
||||
import {
|
||||
GHIDRA_INVENTORY_OPERATIONS,
|
||||
parseGhidraInventoryInput,
|
||||
parseGhidraInventoryResult,
|
||||
} from "../dist/ghidra/GhidraInventoryValues.js";
|
||||
import {
|
||||
inspectGhidraInstallation,
|
||||
SUPPORTED_GHIDRA_VERSION,
|
||||
} from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
|
||||
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProvider.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES as SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
|
||||
import { parseBinaryTarget } from "../dist/domain/binaryTarget.js";
|
||||
|
||||
if (process.platform !== "win32" || process.arch !== "x64")
|
||||
throw new Error(
|
||||
"The real Windows Ghidra verifier requires a Windows x64 host",
|
||||
);
|
||||
const installDir = process.env.GHIDRA_INSTALL_DIR;
|
||||
if (installDir === undefined || !isAbsolute(installDir))
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to the absolute root of Ghidra 12.1.2.",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
installDir,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (
|
||||
!installation.available ||
|
||||
installation.analyzeHeadlessPath === null ||
|
||||
installation.providerVersion !== SUPPORTED_GHIDRA_VERSION
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra installation is unavailable: ${JSON.stringify(installation)}`,
|
||||
);
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const targetPath = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe");
|
||||
const target = await parseBinaryTarget(targetPath);
|
||||
if (
|
||||
!target.ok ||
|
||||
target.value.format !== "pe" ||
|
||||
target.value.architecture !== "x86_64" ||
|
||||
target.value.executableRole !== "application" ||
|
||||
target.value.managed !== false
|
||||
)
|
||||
throw new Error(
|
||||
`Controlled Windows fixture failed admission: ${JSON.stringify(target)}`,
|
||||
);
|
||||
const profile = await resolveGhidraAnalysisProfile(
|
||||
target.value,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
installation,
|
||||
);
|
||||
if (!profile.ok || profile.value.profile === null)
|
||||
throw new Error("Windows Ghidra profile could not be committed");
|
||||
|
||||
const client = new GhidraClient({
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
bridgeScriptPath: fileURLToPath(
|
||||
new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
|
||||
),
|
||||
platform: "win32",
|
||||
}),
|
||||
targetPath: target.value.path,
|
||||
targetSha256: target.value.sha256,
|
||||
transport: "authenticated-loopback-tcp",
|
||||
providerVersion: SUPPORTED_GHIDRA_VERSION,
|
||||
profileDigest: profile.value.profile.digest,
|
||||
requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let coordinates;
|
||||
let report;
|
||||
const observed = new Set();
|
||||
try {
|
||||
const started = await client.start();
|
||||
if (!started.ok) throw started.error;
|
||||
assertSession(
|
||||
started.value,
|
||||
target.value.sha256,
|
||||
profile.value.profile.digest,
|
||||
);
|
||||
coordinates = client.diagnostics();
|
||||
assertRuntimeCoordinates(coordinates, target.value.sha256);
|
||||
|
||||
const documents = await inventory("list_documents", {});
|
||||
const names = await inventory("list_names", {});
|
||||
const procedures = await inventory("list_procedures", {});
|
||||
const segments = await inventory("list_segments", {});
|
||||
const strings = await inventory("list_strings", {});
|
||||
if (
|
||||
!Array.isArray(documents) ||
|
||||
documents.length !== 1 ||
|
||||
!pageHasItems(procedures) ||
|
||||
!Array.isArray(segments) ||
|
||||
segments.length === 0 ||
|
||||
!isPage(names) ||
|
||||
!isPage(strings)
|
||||
)
|
||||
throw new Error("Windows Ghidra inventory proof was incomplete");
|
||||
const procedure = procedures.items.find(
|
||||
(item) => item.procedure?.external === false,
|
||||
);
|
||||
if (procedure === undefined)
|
||||
throw new Error("Windows Ghidra fixture exposed no local procedure");
|
||||
|
||||
await inventory("address_name", { address: procedure.address });
|
||||
const resolvedAddress = await inventory("procedure_address", {
|
||||
procedure: procedure.value,
|
||||
});
|
||||
if (resolvedAddress !== procedure.address)
|
||||
throw new Error("Windows procedure name/address resolution drifted");
|
||||
await inventory("resolve_containing_procedure", {
|
||||
address: procedure.address,
|
||||
});
|
||||
await inventory("search_procedures", { pattern: procedure.value });
|
||||
await inventory("search_strings", { pattern: "rea" });
|
||||
|
||||
for (const operation of [
|
||||
"procedure_assembly",
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
])
|
||||
await functionOperation(operation, { procedure: procedure.value });
|
||||
await functionOperation("xrefs", { address: procedure.address });
|
||||
await functionOperation("analyze_function", {
|
||||
procedure: procedure.value,
|
||||
include_assembly: true,
|
||||
});
|
||||
|
||||
const expected = [
|
||||
...GHIDRA_INVENTORY_OPERATIONS,
|
||||
...GHIDRA_FUNCTION_OPERATIONS,
|
||||
];
|
||||
if (
|
||||
observed.size !== expected.length ||
|
||||
expected.some((operation) => !observed.has(operation))
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
|
||||
);
|
||||
|
||||
report = {
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
target: {
|
||||
format: target.value.format,
|
||||
architecture: target.value.architecture,
|
||||
executable_role: target.value.executableRole,
|
||||
managed: target.value.managed,
|
||||
sha256: target.value.sha256,
|
||||
},
|
||||
bridge_version: started.value.bridge_version,
|
||||
transport: "authenticated-loopback-tcp",
|
||||
operations: [...observed].sort((left, right) => left.localeCompare(right)),
|
||||
cleanup: "complete",
|
||||
limitations: [
|
||||
"approved-non-sensitive-fixtures-only",
|
||||
"no-job-object-ownership",
|
||||
"no-private-dacl-proof",
|
||||
"no-reparse-point-authority",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
if (coordinates !== undefined) await assertCleanup(coordinates);
|
||||
}
|
||||
if (report === undefined)
|
||||
throw new Error("Windows Ghidra report was not produced");
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
|
||||
async function inventory(operation, parameters) {
|
||||
const input = parseGhidraInventoryInput(operation, parameters);
|
||||
if (!input.ok) throw input.error;
|
||||
const called = await client.callTool(operation, input.value);
|
||||
if (!called.ok) throw called.error;
|
||||
const result = parseGhidraInventoryResult(operation, called.value);
|
||||
if (!result.ok) throw result.error;
|
||||
observed.add(operation);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async function functionOperation(operation, parameters) {
|
||||
const input = parseGhidraFunctionInput(operation, parameters);
|
||||
if (!input.ok) throw input.error;
|
||||
const called = await client.callTool(operation, input.value, {
|
||||
timeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
if (!called.ok) throw called.error;
|
||||
const result = parseGhidraFunctionResult(operation, called.value);
|
||||
if (!result.ok) throw result.error;
|
||||
observed.add(operation);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function assertSession(session, targetSha256, profileDigest) {
|
||||
if (
|
||||
session.provider.id !== "ghidra" ||
|
||||
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
|
||||
session.profile_digest !== profileDigest ||
|
||||
session.target.sha256 !== targetSha256 ||
|
||||
session.read_only !== true ||
|
||||
session.analysis_complete !== true ||
|
||||
session.analysis_timed_out !== false ||
|
||||
session.capabilities.join(",") !== SESSION_CAPABILITIES.join(",")
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra session commitment drifted: ${JSON.stringify(session)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertRuntimeCoordinates(runtime, targetSha256) {
|
||||
if (
|
||||
runtime.transport !== "authenticated-loopback-tcp" ||
|
||||
runtime.target_sha256 !== targetSha256 ||
|
||||
typeof runtime.runtime_root !== "string" ||
|
||||
typeof runtime.endpoint_path !== "string" ||
|
||||
typeof runtime.project_root !== "string" ||
|
||||
typeof runtime.process_id !== "number"
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra runtime coordinates are incomplete: ${JSON.stringify(runtime)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCleanup(runtime) {
|
||||
for (const name of ["runtime_root", "endpoint_path", "project_root"]) {
|
||||
const path = runtime[name];
|
||||
if (typeof path === "string" && (await exists(path)))
|
||||
throw new Error(`Windows Ghidra cleanup left ${name}: ${path}`);
|
||||
}
|
||||
if (
|
||||
typeof runtime.process_id === "number" &&
|
||||
processExists(runtime.process_id)
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra cleanup left process ${String(runtime.process_id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const isPage = (value) =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Array.isArray(value.items);
|
||||
|
||||
const pageHasItems = (value) => isPage(value) && value.items.length > 0;
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function processExists(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
return !(
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ESRCH"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -200,10 +200,18 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
|||
try {
|
||||
const started = await client.start();
|
||||
if (!started.ok) throw started.error;
|
||||
assertSession(started.value, profile.value.profile.digest);
|
||||
assertSession(
|
||||
started.value,
|
||||
profile.value.profile.digest,
|
||||
parsedTarget.value.sha256,
|
||||
);
|
||||
const pinged = await client.ping();
|
||||
if (!pinged.ok) throw pinged.error;
|
||||
assertSession(pinged.value, profile.value.profile.digest);
|
||||
assertSession(
|
||||
pinged.value,
|
||||
profile.value.profile.digest,
|
||||
parsedTarget.value.sha256,
|
||||
);
|
||||
runtimeCoordinates = client.diagnostics();
|
||||
assertRuntimeCoordinates(runtimeCoordinates);
|
||||
|
||||
|
|
@ -1027,11 +1035,12 @@ function symbolTail(value) {
|
|||
return value.split("::").at(-1);
|
||||
}
|
||||
|
||||
function assertSession(session, expectedDigest) {
|
||||
function assertSession(session, expectedDigest, expectedTargetSha256) {
|
||||
if (
|
||||
session.provider.id !== "ghidra" ||
|
||||
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
|
||||
session.profile_digest !== expectedDigest ||
|
||||
session.target.sha256 !== expectedTargetSha256 ||
|
||||
session.read_only !== true ||
|
||||
session.analysis_complete !== true ||
|
||||
session.analysis_timed_out !== false ||
|
||||
|
|
@ -1045,7 +1054,7 @@ function assertSession(session, expectedDigest) {
|
|||
}
|
||||
|
||||
function assertRuntimeCoordinates(coordinates) {
|
||||
for (const name of ["runtime_root", "socket_path", "project_root"])
|
||||
for (const name of ["runtime_root", "endpoint_path", "project_root"])
|
||||
if (typeof coordinates[name] !== "string")
|
||||
throw new Error(`Ghidra verifier did not observe ${name}`);
|
||||
if (typeof coordinates.process_id !== "number")
|
||||
|
|
@ -1053,7 +1062,7 @@ function assertRuntimeCoordinates(coordinates) {
|
|||
}
|
||||
|
||||
async function assertCleanup(coordinates) {
|
||||
for (const name of ["runtime_root", "socket_path", "project_root"]) {
|
||||
for (const name of ["runtime_root", "endpoint_path", "project_root"]) {
|
||||
const path = coordinates[name];
|
||||
if (typeof path === "string" && (await exists(path)))
|
||||
throw new Error(`Ghidra cleanup left ${name}: ${path}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-windows-package-"));
|
||||
const prefix = join(workspace, "prefix");
|
||||
|
||||
try {
|
||||
const packed = JSON.parse(
|
||||
(
|
||||
await npm(
|
||||
["pack", "--json", "--silent", "--pack-destination", workspace],
|
||||
root,
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
const packageResult = packed[0];
|
||||
if (
|
||||
packageResult === undefined ||
|
||||
typeof packageResult.filename !== "string" ||
|
||||
!Array.isArray(packageResult.files)
|
||||
)
|
||||
throw new Error(
|
||||
`npm pack returned invalid metadata: ${JSON.stringify(packed)}`,
|
||||
);
|
||||
const packagedPaths = new Set(packageResult.files.map(({ path }) => path));
|
||||
for (const required of [
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"dist/main.js",
|
||||
"dist/cli.js",
|
||||
"scripts/rea.mjs",
|
||||
])
|
||||
if (!packagedPaths.has(required))
|
||||
throw new Error(`Windows package omitted ${required}`);
|
||||
|
||||
const tarball = join(workspace, packageResult.filename);
|
||||
await access(tarball);
|
||||
await npm(
|
||||
["install", "--no-package-lock", "--no-save", "--prefix", prefix, tarball],
|
||||
workspace,
|
||||
);
|
||||
const entry = join(
|
||||
prefix,
|
||||
"node_modules",
|
||||
"rea-agents",
|
||||
"scripts",
|
||||
"rea.mjs",
|
||||
);
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
};
|
||||
const help = await exec(process.execPath, [entry, "--help"], {
|
||||
env: environment,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!help.stdout.includes("open") || !help.stdout.includes("providers"))
|
||||
throw new Error("Packaged Windows CLI help omitted analysis commands");
|
||||
|
||||
const server = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entry, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-windows-package", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(server);
|
||||
const catalog = await client.listTools();
|
||||
const expected = TOOL_CONTRACTS.map(({ name }) => name).sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
const actual = catalog.tools
|
||||
.map(({ name }) => name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
||||
throw new Error(
|
||||
"Packaged Windows MCP catalog drifted from TOOL_CONTRACTS",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
platform: process.platform,
|
||||
package: packageResult.filename,
|
||||
tools: TOOL_CONTRACTS.length,
|
||||
ghidra_bridge: "present",
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function npm(arguments_, cwd) {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
return npmExecPath === undefined
|
||||
? exec(process.platform === "win32" ? "npm.cmd" : "npm", arguments_, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
})
|
||||
: exec(process.execPath, [npmExecPath, ...arguments_], {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -189,21 +189,24 @@ describe("binary target I/O", () => {
|
|||
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an app program symlink that leaves the bundle", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
const app = join(directory, "Escaping.app");
|
||||
const contents = join(app, "Contents");
|
||||
const programs = join(contents, "MacOS");
|
||||
const outside = join(directory, "outside");
|
||||
await mkdir(programs, { recursive: true });
|
||||
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
|
||||
);
|
||||
await symlink(outside, join(programs, "Escaping"));
|
||||
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
|
||||
});
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects an app program symlink that leaves the bundle",
|
||||
async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
const app = join(directory, "Escaping.app");
|
||||
const contents = join(app, "Contents");
|
||||
const programs = join(contents, "MacOS");
|
||||
const outside = join(directory, "outside");
|
||||
await mkdir(programs, { recursive: true });
|
||||
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
|
||||
);
|
||||
await symlink(outside, join(programs, "Escaping"));
|
||||
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("honors an explicit database kind without relying on the file suffix", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseExecutableHeader } from "../src/domain/binaryTarget.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const generator = resolve(root, "scripts", "create-ghidra-windows-fixture.mjs");
|
||||
const fixture = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe");
|
||||
const expectedSha256 =
|
||||
"732ec6083ae3b63bcbda33f2d0fd7e1a6539babade07cbdae4fc20d3394faf49";
|
||||
|
||||
describe("controlled Windows Ghidra fixture", () => {
|
||||
it("generates one deterministic native x86-64 PE application", async () => {
|
||||
await exec(process.execPath, [generator]);
|
||||
const bytes = await readFile(fixture);
|
||||
|
||||
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
||||
expectedSha256,
|
||||
);
|
||||
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("real Windows Ghidra workflow trust boundary", () => {
|
||||
it("runs only default-branch code on the fixed self-hosted environment", async () => {
|
||||
const workflow = await readFile(
|
||||
new URL("../.github/workflows/real-ghidra-windows.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(workflow).toContain("types: [real-ghidra-windows]");
|
||||
expect(workflow).not.toContain("workflow_dispatch:");
|
||||
expect(workflow).toContain("contents: read");
|
||||
expect(workflow).toContain("if: github.ref == 'refs/heads/main'");
|
||||
expect(workflow).toContain("environment: real-ghidra-windows");
|
||||
expect(workflow).toContain(
|
||||
"runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]",
|
||||
);
|
||||
expect(workflow).not.toMatch(
|
||||
/\$\{\{\s*(?:inputs|github\.event\.(?:inputs|client_payload))\b/u,
|
||||
);
|
||||
expect(workflow).not.toContain("cache: npm");
|
||||
expect(workflow).toContain("npm run verify:ghidra:windows");
|
||||
expect(workflow).toContain("windows-ghidra-proof.log");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue