diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3894e956b..7b84c8c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/real-ghidra-windows.yml b/.github/workflows/real-ghidra-windows.yml new file mode 100644 index 000000000..27f9e65c7 --- /dev/null +++ b/.github/workflows/real-ghidra-windows.yml @@ -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 diff --git a/package.json b/package.json index 6d312c5ef..9e1b1220c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/create-ghidra-windows-fixture.mjs b/scripts/create-ghidra-windows-fixture.mjs new file mode 100644 index 000000000..fcb1df5f2 --- /dev/null +++ b/scripts/create-ghidra-windows-fixture.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`); diff --git a/scripts/verify-real-ghidra-windows.mjs b/scripts/verify-real-ghidra-windows.mjs new file mode 100644 index 000000000..6442fa1f2 --- /dev/null +++ b/scripts/verify-real-ghidra-windows.mjs @@ -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" + ); + } +} diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index 5b03d2edd..8bf4b7507 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -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}`); diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs new file mode 100644 index 000000000..48cd5b327 --- /dev/null +++ b/scripts/verify-windows-package.mjs @@ -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, + }); +} diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts index aaa1dd1ee..82dac07db 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/binaryTarget.test.ts @@ -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"), - "CFBundleExecutableEscaping", - ); - 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"), + "CFBundleExecutableEscaping", + ); + 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-")); diff --git a/tests/ghidraWindowsFixture.test.ts b/tests/ghidraWindowsFixture.test.ts new file mode 100644 index 000000000..4e27080a9 --- /dev/null +++ b/tests/ghidraWindowsFixture.test.ts @@ -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, + }, + }); + }); +}); diff --git a/tests/realGhidraWindowsWorkflow.test.ts b/tests/realGhidraWindowsWorkflow.test.ts new file mode 100644 index 000000000..6aca5b5de --- /dev/null +++ b/tests/realGhidraWindowsWorkflow.test.ts @@ -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"); + }); +});