mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): defer lazy loads and tighten Mach-O validation
Address review findings in trace_dylib_resolution: - Resolve LC_LAZY_LOAD_DYLIB dependencies without traversing them, since dyld loads them on first use. An unresolved one becomes `lazy-load-unresolved`, not a launch failure. - Accept FAT tables up to 128 records, as target opening does. A table that names no Mach-O CPU type, such as a Java class file, is not Mach-O. - Require 8-byte load-command alignment in 64-bit images. 1,508 real slices still match otool. - Report EACCES/EPERM while probing, reading, or hashing as an `unavailable` failure naming the code and path, rather than generic io. Findings and limitations move to dylibResolutionFindings.ts to keep the resolver under the module size limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0ec3e2016
commit
91280393cd
12 files changed
+256
-78
No files matched your search
@@ -167,7 +167,9 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
|
||||
`findings` are derived, not observed:
|
||||
|
||||
- `required-load-unresolved` and `weak-load-unresolved`.
|
||||
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
|
||||
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
|
||||
loads them on first use; an unresolved one is not a launch failure.
|
||||
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
|
||||
would load first. Whether code-signing library validation would reject it is
|
||||
not evaluated; use `inspect_signature`.
|
||||
|
||||
@@ -672,9 +672,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "7ad6e57f4a82e441c28969242a6e2f098871b48388049cb6b1ebac161ba3d699",
|
||||
"tools_sha256": "a99b4fdcb450237aa910b549b0cd8aaf13a85f25b5c631379ac4cb4de75767c7",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200",
|
||||
"combined_sha256": "39493ff570450eac6dd889bf94685b1180d48311bee1a67e0801e6ed531abd64",
|
||||
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_93f08d561a3f3decdeeb2350630274ea7404a6b072908b213c853b97c8adca1d",
|
||||
"manifest_id": "ecm_7ac92161eaf2ccc3146a8e49390a73e5d642f1e6918213be67118383410522e5",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "d65a570cd63b6fb73dc44fc5a08122fdfa7d9ce43f3dfd4b4d2b18950ea2387b"
|
||||
"sha256": "6ea0efd0566072b6f504913a367bbc38035f57f42f5353410ad9081aaf5fe6c9"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
||||
metadata:
|
||||
version: "28"
|
||||
tool_count: 132
|
||||
catalog_digest: "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200"
|
||||
catalog_digest: "39493ff570450eac6dd889bf94685b1180d48311bee1a67e0801e6ed531abd64"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -123,6 +123,13 @@ export const traceDylibResolution = async (options: {
|
||||
"Dylib resolution was cancelled",
|
||||
{ cause },
|
||||
);
|
||||
const denied = permissionDenied(cause);
|
||||
if (denied !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Permission denied (${denied.code}) reading ${denied.path === undefined ? "a file in the analyzed root" : relative(root, denied.path) || denied.path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -242,6 +249,21 @@ const cancelled = (signal?: AbortSignal): void => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Host permission denials, kept distinct from malformed or missing files. */
|
||||
const permissionDenied = (
|
||||
cause: unknown,
|
||||
): { readonly code: string; readonly path: string | undefined } | undefined => {
|
||||
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
|
||||
if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
|
||||
return {
|
||||
code: cause.code,
|
||||
path:
|
||||
"path" in cause && typeof cause.path === "string"
|
||||
? cause.path
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const missing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
|
||||
@@ -210,6 +210,35 @@ describe("Mach-O load command reader failures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires 8-byte command alignment in 64-bit images", async () => {
|
||||
const command = new Uint8Array(12);
|
||||
const view = new DataView(command.buffer);
|
||||
view.setUint32(0, LC.CODE_SIGNATURE, true);
|
||||
view.setUint32(4, 12, true);
|
||||
expect(await read(machoImage({ commands: [command] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("invalid cmdsize 12"),
|
||||
});
|
||||
expect(
|
||||
await read(
|
||||
machoImage({
|
||||
cpu: { type: 7, subtype: 3 },
|
||||
wide: false,
|
||||
commands: [command],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ status: "parsed" });
|
||||
});
|
||||
|
||||
it("accepts FAT tables up to 128 records that name a Mach-O CPU", async () => {
|
||||
const slices = Array.from({ length: 24 }, () => ({
|
||||
cpu: CPU.arm64,
|
||||
bytes: machoImage({}),
|
||||
}));
|
||||
const facts = await read(fatImage(slices));
|
||||
expect(facts.status === "parsed" && facts.slices).toHaveLength(24);
|
||||
});
|
||||
|
||||
it("rejects string commands too short to hold their string offset", async () => {
|
||||
const short = new Uint8Array(8);
|
||||
const view = new DataView(short.buffer);
|
||||
|
||||
@@ -9,8 +9,12 @@ export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
/** Load commands larger than this are refused instead of buffered. */
|
||||
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
|
||||
/** Real universal binaries carry a handful of slices; Java class files share the FAT magic. */
|
||||
const MAX_FAT_ARCHITECTURES = 20;
|
||||
/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */
|
||||
const MAX_FAT_ARCHITECTURES = 128;
|
||||
/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */
|
||||
const KNOWN_CPU_TYPES: readonly number[] = [
|
||||
7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
|
||||
];
|
||||
|
||||
const MH_MAGIC = 0xfeedface;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
@@ -91,12 +95,26 @@ export const readMachoImage = async (
|
||||
// FAT headers are big-endian; the swapped magics store them little-endian.
|
||||
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
|
||||
const count = view.getUint32(4, littleEndian);
|
||||
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
|
||||
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
|
||||
if (
|
||||
count === 0 ||
|
||||
count > MAX_FAT_ARCHITECTURES ||
|
||||
8 + count * (wide ? 32 : 20) > size
|
||||
)
|
||||
return { status: "not-mach-o" };
|
||||
const table = viewOf(
|
||||
await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
|
||||
);
|
||||
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
|
||||
const cpuTypes = Array.from({ length: count }, (_, index) =>
|
||||
table.getUint32(index * (wide ? 32 : 20), littleEndian),
|
||||
);
|
||||
if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
|
||||
return { status: "not-mach-o" };
|
||||
return {
|
||||
status: "parsed",
|
||||
slices: await readFatSlices(readAt, size, {
|
||||
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
|
||||
wide,
|
||||
littleEndian,
|
||||
count,
|
||||
}),
|
||||
@@ -179,6 +197,7 @@ const readSlice = async (
|
||||
"load commands",
|
||||
);
|
||||
return decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: header.getUint32(4, true),
|
||||
cpuSubtype: header.getUint32(8, true),
|
||||
fileType: header.getUint32(12, true),
|
||||
@@ -198,12 +217,15 @@ const readSlice = async (
|
||||
const decodeCommands = (
|
||||
bytes: Uint8Array,
|
||||
header: {
|
||||
readonly wide: boolean;
|
||||
readonly cpuType: number;
|
||||
readonly cpuSubtype: number;
|
||||
readonly fileType: number;
|
||||
readonly commandCount: number;
|
||||
},
|
||||
): MachoSlice => {
|
||||
// loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
|
||||
const alignment = header.wide ? 8 : 4;
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -223,7 +245,7 @@ const decodeCommands = (
|
||||
);
|
||||
const command = view.getUint32(offset, true);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (size < 8 || size % 4 !== 0 || offset + size > bytes.byteLength)
|
||||
if (size < 8 || size % alignment !== 0 || offset + size > bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
|
||||
@@ -518,3 +518,32 @@ describe("dyld slice compatibility and coverage", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lazily loaded dependencies", () => {
|
||||
it("resolves lazy loads without traversing them or reporting launch failure", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/liblazy.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB",
|
||||
}),
|
||||
dependency("@rpath/libmissing.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/liblazy.dylib": parsed(
|
||||
slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges.map(({ loader }) => loader)).toEqual([MAIN, MAIN]);
|
||||
expect(trace.edges[0]?.resolution.status).toBe("resolved");
|
||||
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
type Expansion,
|
||||
} from "./dyldPaths.js";
|
||||
import { digestSchema } from "./digests.js";
|
||||
import {
|
||||
DYLIB_RESOLUTION_LIMITATIONS,
|
||||
deriveFindings,
|
||||
} from "./dylibResolutionFindings.js";
|
||||
|
||||
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
|
||||
const ROOT_RELATIVE_PATH =
|
||||
@@ -118,6 +122,7 @@ const findingSchema = z.strictObject({
|
||||
kind: z.enum([
|
||||
"required-load-unresolved",
|
||||
"weak-load-unresolved",
|
||||
"lazy-load-unresolved",
|
||||
"earlier-rpath-candidate-absent",
|
||||
"dyld-environment-present",
|
||||
]),
|
||||
@@ -155,8 +160,9 @@ export const dylibResolutionResultSchema = z.strictObject({
|
||||
|
||||
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
|
||||
type Candidate = z.infer<typeof candidateSchema>;
|
||||
type Edge = z.infer<typeof edgeSchema>;
|
||||
type Finding = z.infer<typeof findingSchema>;
|
||||
export type DylibEdge = z.infer<typeof edgeSchema>;
|
||||
export type DylibFinding = z.infer<typeof findingSchema>;
|
||||
type Edge = DylibEdge;
|
||||
|
||||
/** Result before the adapter adds file digests and the analyzed root. */
|
||||
export type DylibTrace = Omit<
|
||||
@@ -365,6 +371,8 @@ const traceProcess = async (
|
||||
for (const dependency of current.slice.dependencies) {
|
||||
const edge = await resolveDependency(dependency, loader, context);
|
||||
edges.push(edge);
|
||||
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
|
||||
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
@@ -476,68 +484,5 @@ const withoutDependencies = (
|
||||
code_signature_present: slice.code_signature_present,
|
||||
});
|
||||
|
||||
const deriveFindings = (
|
||||
edges: readonly Edge[],
|
||||
roots: DylibTrace["roots"],
|
||||
images: ReadonlyMap<string, MachoImageFacts>,
|
||||
): Finding[] => {
|
||||
const findings: Finding[] = [];
|
||||
edges.forEach((edge, index) => {
|
||||
if (edge.resolution.status === "unresolved")
|
||||
findings.push({
|
||||
kind: edge.weak ? "weak-load-unresolved" : "required-load-unresolved",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: edge.weak
|
||||
? `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`
|
||||
: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
|
||||
});
|
||||
const resolvedAt = edge.candidates.findIndex(
|
||||
({ outcome }) => outcome === "resolved",
|
||||
);
|
||||
const earlier = edge.candidates
|
||||
.slice(0, Math.max(resolvedAt, 0))
|
||||
.filter(
|
||||
({ source, outcome }) => source === "rpath" && outcome === "absent",
|
||||
)
|
||||
.map(({ path }) => path);
|
||||
if (resolvedAt > 0 && earlier.length > 0)
|
||||
findings.push({
|
||||
kind: "earlier-rpath-candidate-absent",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
|
||||
});
|
||||
});
|
||||
for (const { image, architecture } of roots) {
|
||||
const facts = images.get(image);
|
||||
const environment =
|
||||
facts?.status === "parsed"
|
||||
? (facts.slices.find((slice) => slice.architecture === architecture)
|
||||
?.dyld_environment ?? [])
|
||||
: [];
|
||||
if (environment.length > 0)
|
||||
findings.push({
|
||||
kind: "dyld-environment-present",
|
||||
edge_index: null,
|
||||
image,
|
||||
basis: "derived",
|
||||
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
};
|
||||
|
||||
const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
|
||||
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
|
||||
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
|
||||
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
|
||||
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
|
||||
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
|
||||
];
|
||||
|
||||
const compare = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
DylibEdge as Edge,
|
||||
DylibFinding as Finding,
|
||||
DylibTrace,
|
||||
MachoImageFacts,
|
||||
} from "./dylibResolution.js";
|
||||
|
||||
export const deriveFindings = (
|
||||
edges: readonly Edge[],
|
||||
roots: DylibTrace["roots"],
|
||||
images: ReadonlyMap<string, MachoImageFacts>,
|
||||
): Finding[] => {
|
||||
const findings: Finding[] = [];
|
||||
edges.forEach((edge, index) => {
|
||||
if (edge.resolution.status === "unresolved")
|
||||
findings.push(unresolvedFinding(edge, index));
|
||||
const resolvedAt = edge.candidates.findIndex(
|
||||
({ outcome }) => outcome === "resolved",
|
||||
);
|
||||
const earlier = edge.candidates
|
||||
.slice(0, Math.max(resolvedAt, 0))
|
||||
.filter(
|
||||
({ source, outcome }) => source === "rpath" && outcome === "absent",
|
||||
)
|
||||
.map(({ path }) => path);
|
||||
if (resolvedAt > 0 && earlier.length > 0)
|
||||
findings.push({
|
||||
kind: "earlier-rpath-candidate-absent",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
|
||||
});
|
||||
});
|
||||
for (const { image, architecture } of roots) {
|
||||
const facts = images.get(image);
|
||||
const environment =
|
||||
facts?.status === "parsed"
|
||||
? (facts.slices.find((slice) => slice.architecture === architecture)
|
||||
?.dyld_environment ?? [])
|
||||
: [];
|
||||
if (environment.length > 0)
|
||||
findings.push({
|
||||
kind: "dyld-environment-present",
|
||||
edge_index: null,
|
||||
image,
|
||||
basis: "derived",
|
||||
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
};
|
||||
|
||||
const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
const base = {
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
} as const;
|
||||
if (edge.command === "LC_LAZY_LOAD_DYLIB")
|
||||
return {
|
||||
...base,
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation: `No candidate for lazily loaded ${edge.install_name} exists in the analyzed root; dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
|
||||
};
|
||||
return edge.weak
|
||||
? {
|
||||
...base,
|
||||
kind: "weak-load-unresolved",
|
||||
explanation: `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
kind: "required-load-unresolved",
|
||||
explanation: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
|
||||
};
|
||||
};
|
||||
|
||||
export const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
|
||||
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
|
||||
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
|
||||
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
|
||||
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
|
||||
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
|
||||
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
|
||||
];
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -192,3 +192,45 @@ describe("trace-dylib-resolution CLI", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user