createAnalysisExecution defaulted an omitted rawResult to a second complete copy of the result. Every caller that had no distinct provider representation therefore doubled its Evidence: decode_interface_builder, inspect_keyed_archive, inspect_asset_catalog, trace_dylib_resolution, inspect_native_dispatch_metadata, observe_native_calls, observe_native_ui/capture_native_ui_scenario (including screenshots), and small Ghidra/IDA lifecycle records. Sibling operations of the same providers already recorded null. An omitted rawResult now records null. Normalized results are unchanged. MCP responses repeat Evidence in text and structured content, so each response and its share of the 10 MiB transport budget shrink by the same half; e.g. trace_dylib_resolution on a real app bundle drops from 3,217,119 to 1,612,524 bytes per representation. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { describe, expect } from "vitest";
|
|
|
|
import {
|
|
FILE_TYPE,
|
|
LC,
|
|
buildVersionCommand,
|
|
dylibCommand,
|
|
dyldEnvironmentCommand,
|
|
machoImage,
|
|
rpathCommand,
|
|
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
|
import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
|
|
|
const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
|
|
|
|
const writeFiles = async (
|
|
root: string,
|
|
files: Readonly<Record<string, string | Uint8Array>>,
|
|
): Promise<void> => {
|
|
for (const [path, content] of Object.entries(files)) {
|
|
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
await writeFile(join(root, path), content);
|
|
}
|
|
};
|
|
|
|
/** A caller-selected root that cannot be traced is an input issue on that root. */
|
|
const expectRootInputError = async (
|
|
cli: TestCli,
|
|
{
|
|
app,
|
|
root,
|
|
reason,
|
|
message,
|
|
}: {
|
|
readonly app: string;
|
|
readonly root: string;
|
|
readonly reason: string;
|
|
readonly message: string;
|
|
},
|
|
): Promise<void> => {
|
|
const rejected = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(rejected.exitCode).toBe(1);
|
|
expect(rejected.json).toMatchObject({
|
|
error: "Analysis failed",
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["roots", 0],
|
|
reason,
|
|
message: expect.stringContaining(message),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
};
|
|
|
|
const fixtureApp = async (): Promise<string> => {
|
|
const app = join(
|
|
await createTestTempDirectory("rea-dylib-cli-"),
|
|
"Fixture.app",
|
|
);
|
|
await writeFiles(app, {
|
|
"Contents/Info.plist":
|
|
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
|
"Contents/MacOS/App": machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
rpathCommand("@executable_path/../Frameworks"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
|
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Frameworks/libcore.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Helpers/tool": machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
|
|
],
|
|
}),
|
|
});
|
|
return app;
|
|
};
|
|
|
|
const withBrokenHelper = async (app: string): Promise<void> => {
|
|
const broken = machoImage({ commands: [rpathCommand("x")] });
|
|
// cmdsize 0 makes the command table unreadable.
|
|
new DataView(broken.buffer).setUint32(32 + 4, 0, true);
|
|
await writeFiles(app, { "Contents/Helpers/broken": broken });
|
|
};
|
|
|
|
describe("trace-dylib-resolution CLI", () => {
|
|
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.root_path).toBe(app);
|
|
expect(trace.roots.map(({ image }) => image)).toEqual([
|
|
"Contents/Helpers/tool",
|
|
"Contents/MacOS/App",
|
|
]);
|
|
// The normalized result is the provider's only representation; Evidence
|
|
// must not carry it a second time as raw_result.
|
|
expect(result.json).toMatchObject({ raw_result: null });
|
|
expect(
|
|
trace.edges.map(({ loader, install_name: name, resolution }) => [
|
|
loader,
|
|
name,
|
|
resolution.status,
|
|
resolution.image,
|
|
]),
|
|
).toEqual([
|
|
[
|
|
"Contents/Helpers/tool",
|
|
"@loader_path/libnone.dylib",
|
|
"unresolved",
|
|
null,
|
|
],
|
|
[
|
|
"Contents/MacOS/App",
|
|
"@rpath/libcore.dylib",
|
|
"resolved",
|
|
"Contents/Frameworks/libcore.dylib",
|
|
],
|
|
["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
|
|
]);
|
|
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
|
"required-load-unresolved",
|
|
"weak-load-unresolved",
|
|
]);
|
|
expect(
|
|
trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
|
|
).toBe(true);
|
|
});
|
|
|
|
cliTest(
|
|
"narrows roots and architecture, and rejects escaping roots",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const narrowed = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/MacOS/App",
|
|
"--architecture",
|
|
"arm64",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(narrowed.exitCode).toBe(0);
|
|
expect(
|
|
(narrowed.json as { normalized_result: { roots: unknown } })
|
|
.normalized_result.roots,
|
|
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
|
|
// A root below a symlinked directory that points outside the bundle.
|
|
const outside = await createTestTempDirectory("rea-dylib-outside-");
|
|
await writeFiles(outside, { Tool: machoImage({}) });
|
|
await symlink(outside, join(app, "Contents/Linked"));
|
|
for (const [root, reason, message] of [
|
|
["../outside", "invalid_format", "Use a normalized path"],
|
|
["Contents/Info.plist", "invalid_value", "is not a Mach-O image"],
|
|
[
|
|
"Contents/Linked/Tool",
|
|
"invalid_value",
|
|
"resolves outside the analyzed root",
|
|
],
|
|
] as const) {
|
|
await expectRootInputError(cli, { app, root, reason, message });
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"resolves a standalone Mach-O against its own directory",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-dylib-standalone-");
|
|
await writeFiles(directory, {
|
|
tool: machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
|
|
],
|
|
}),
|
|
"lib/libhelper.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [buildVersionCommand(1)],
|
|
}),
|
|
"unrelated.txt": "not traversed",
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
join(directory, "tool"),
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
|
|
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
|
|
{ status: "resolved", image: "lib/libhelper.dylib" },
|
|
{ status: "undetermined", image: null },
|
|
]);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports unparsed Mach-O files found while enumerating roots",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
await withBrokenHelper(app);
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.coverage).toMatchObject({
|
|
status: "partial",
|
|
unparsed_images: ["Contents/Helpers/broken"],
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe.skipIf(process.getuid?.() === 0)(
|
|
"trace-dylib-resolution permissions",
|
|
() => {
|
|
cliTest(
|
|
"reports an unreadable dependency as a permission failure",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-dylib-denied-");
|
|
await writeFiles(directory, {
|
|
tool: machoImage({
|
|
commands: [
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
|
|
],
|
|
}),
|
|
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
|
|
});
|
|
await chmod(join(directory, "libsecret.dylib"), 0o000);
|
|
try {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
join(directory, "tool"),
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.json).toMatchObject({
|
|
details: {
|
|
reason: "unavailable",
|
|
detail: expect.stringContaining(
|
|
"Permission denied (EACCES) reading libsecret.dylib",
|
|
),
|
|
},
|
|
});
|
|
} finally {
|
|
await chmod(join(directory, "libsecret.dylib"), 0o644);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports embedded path overrides as conditional without changing diagnostic-only resolution",
|
|
async ({ cli }) => {
|
|
for (const [setting, expected] of [
|
|
["DYLD_LIBRARY_PATH=/external", "conditional"],
|
|
["DYLD_PRINT_LIBRARIES=1", "resolved"],
|
|
["DYLD_FALLBACK_LIBRARY_PATH=/external", "resolved"],
|
|
["DYLD_FRAMEWORK_PATH=/external", "resolved"],
|
|
["DYLD_ROOT_PATH=/external", "resolved"],
|
|
["DYLD_OVERLAY_PATH=/external", "resolved"],
|
|
] as const) {
|
|
const app = await fixtureApp();
|
|
await writeFiles(app, {
|
|
"Contents/MacOS/App": machoImage({
|
|
commands: [
|
|
dyldEnvironmentCommand(setting),
|
|
buildVersionCommand(1),
|
|
rpathCommand("@executable_path/../Absent"),
|
|
rpathCommand("@executable_path/../Frameworks"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
|
],
|
|
}),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/MacOS/App",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(trace.edges[0]?.resolution.status).toBe(expected);
|
|
expect(trace.coverage.status).toBe(
|
|
expected === "conditional" ? "partial" : "complete",
|
|
);
|
|
expect(
|
|
trace.images.find(({ path }) => path === "Contents/MacOS/App")
|
|
?.slices[0]?.dyld_environment,
|
|
).toEqual([setting]);
|
|
if (expected === "conditional")
|
|
expect(trace.findings[0]?.explanation).toContain(
|
|
"does not establish which image dyld will load",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"retains definitive load findings for an explicitly selected app library root",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const settings = [
|
|
"DYLD_LIBRARY_PATH=/external",
|
|
"DYLD_INSERT_LIBRARIES=/external/injected.dylib",
|
|
];
|
|
await writeFiles(app, {
|
|
"Contents/Frameworks/libcore.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
...settings.map(dyldEnvironmentCommand),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/child.dylib"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Frameworks/child.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [buildVersionCommand(1)],
|
|
}),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/Frameworks/libcore.dylib",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
|
|
"resolved",
|
|
"unresolved",
|
|
]);
|
|
expect(trace.coverage.status).toBe("complete");
|
|
expect(trace.findings.map(({ kind }) => kind)).toContain(
|
|
"required-load-unresolved",
|
|
);
|
|
expect(
|
|
trace.images.find(
|
|
({ path }) => path === "Contents/Frameworks/libcore.dylib",
|
|
)?.slices[0]?.dyld_environment,
|
|
).toEqual(settings);
|
|
expect(
|
|
trace.findings.find(({ kind }) => kind === "dyld-environment-present")
|
|
?.explanation,
|
|
).toContain("entries in this non-executable root are observations");
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"distinguishes empty search directories from empty versioned or suffix lists",
|
|
async ({ cli }) => {
|
|
for (const [setting, status] of [
|
|
["DYLD_FALLBACK_LIBRARY_PATH=", "undetermined"],
|
|
["DYLD_VERSIONED_LIBRARY_PATH=", "unresolved"],
|
|
["DYLD_IMAGE_SUFFIX=:", "unresolved"],
|
|
] as const) {
|
|
const root = await createTestTempDirectory("rea-dyld-empty-cli-");
|
|
const path = join(root, "program");
|
|
await writeFile(
|
|
path,
|
|
machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dyldEnvironmentCommand(setting),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
|
|
],
|
|
}),
|
|
);
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", path, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(trace.edges[0]?.resolution.status).toBe(status);
|
|
expect(trace.coverage.status).toBe(
|
|
status === "undetermined" ? "partial" : "complete",
|
|
);
|
|
expect(
|
|
trace.images.find(({ path }) => path === "program")?.slices[0]
|
|
?.dyld_environment,
|
|
).toEqual([setting]);
|
|
expect(
|
|
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
|
|
).toBe(status === "unresolved");
|
|
}
|
|
},
|
|
);
|