mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): propagate conditional loads and bind digests to parsed images
- Edges below a conditional fallback now carry loader_conditional, reuse of a conditional image by install name is conditional, and their findings say they apply only if that image loads. - Hash each traced image only if it is still the file whose headers were parsed (device, inode, size and change times), before and after hashing. - Resolve explicit roots segment by segment, so a root below a symlinked directory that points outside the analyzed root is rejected. - Require a complete 16-byte LC_CODE_SIGNATURE command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9049620dfe
commit
5e334a0e12
12 files changed
+208
-30
No files matched your search
@@ -163,6 +163,11 @@ graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `r
|
||||
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
|
||||
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
|
||||
|
||||
A `conditional` image is still traced, because it is the image the analyzed
|
||||
root supplies. Every edge below it has `loader_conditional: true`, and a
|
||||
request that reuses it by install name is `conditional` too. Findings on those
|
||||
edges say that they apply only if the conditional image loads.
|
||||
|
||||
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
|
||||
`outside-target`. On macOS 11 and later most of them live in the dyld shared
|
||||
cache rather than on disk, so REA does not check them against the host.
|
||||
|
||||
@@ -672,9 +672,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "23afd2ad272de67c0f3c350e36eb68f3eafa229cd135797a6b4bbdb1acea18d7",
|
||||
"tools_sha256": "7c89ac4dba6aa708f3e5fc7a8b702136833d8ab8c99e117e3256aa690660e7e5",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60",
|
||||
"combined_sha256": "ad4b34f04957f5f4eb15a664684dadd690d820fccc7073a14968c35e10848095",
|
||||
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_0f113449440f920714b896b90f99dfd94ac35b0ba56f56f759c4a77ee9ddd2ba",
|
||||
"manifest_id": "ecm_e06a3c238cfd76f6deee52d24fdaefddc4be80bf633bb5dc43cab72e472e0881",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "2ed9cb3039053459635b32739cf4fbf0ef7670c85ed246aa3b7433d1d7193457"
|
||||
"sha256": "0af79d55c9243e40b5e96b8e1f390284f4c28c98892d1b9fc4a65d1f4064015a"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
||||
metadata:
|
||||
version: "28"
|
||||
tool_count: 132
|
||||
catalog_digest: "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60"
|
||||
catalog_digest: "ad4b34f04957f5f4eb15a664684dadd690d820fccc7073a14968c35e10848095"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
||||
import { lstat, open, readlink, realpath } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import { resolveTreePath } from "../domain/dyldPaths.js";
|
||||
import {
|
||||
dylibResolutionInputSchema,
|
||||
dylibResolutionResultSchema,
|
||||
@@ -18,10 +19,14 @@ import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/** Device, inode, size and change times of the file whose bytes were parsed. */
|
||||
type FileIdentity = string;
|
||||
|
||||
/** Lazily probed, symlink-preserving view of one analyzed directory. */
|
||||
class FilesystemTreeView implements DylibTreeView {
|
||||
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
|
||||
readonly #images = new Map<string, Promise<MachoImageFacts>>();
|
||||
readonly #identities = new Map<string, FileIdentity>();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
@@ -39,11 +44,21 @@ class FilesystemTreeView implements DylibTreeView {
|
||||
image(path: string): Promise<MachoImageFacts> {
|
||||
const cached = this.#images.get(path);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending = readImage(join(this.root, path), this.signal);
|
||||
const pending = readImage(join(this.root, path), this.signal).then(
|
||||
({ facts, identity }) => {
|
||||
this.#identities.set(path, identity);
|
||||
return facts;
|
||||
},
|
||||
);
|
||||
this.#images.set(path, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Identity of the file whose header bytes `image()` parsed. */
|
||||
identity(path: string): FileIdentity | undefined {
|
||||
return this.#identities.get(path);
|
||||
}
|
||||
|
||||
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
cancelled(this.signal);
|
||||
const absolute = join(this.root, path);
|
||||
@@ -81,13 +96,13 @@ export const traceDylibResolution = async (options: {
|
||||
.join("/");
|
||||
const view = new FilesystemTreeView(root, options.signal);
|
||||
try {
|
||||
const { roots, unclassified } =
|
||||
const { roots: requested, unclassified } =
|
||||
parsed.data.roots !== undefined
|
||||
? { roots: parsed.data.roots, unclassified: [] }
|
||||
: options.enumerateRoots
|
||||
? await executableRoots(root, view, options.signal)
|
||||
: { roots: [target], unclassified: [] };
|
||||
await requireMachoRoots(view, roots);
|
||||
const roots = await requireMachoRoots(view, requested);
|
||||
const trace = await traceDylibLoading(view, {
|
||||
roots,
|
||||
unclassified,
|
||||
@@ -96,12 +111,24 @@ export const traceDylibResolution = async (options: {
|
||||
: { architecture: parsed.data.architecture }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
// Each digest must describe the same file whose headers were parsed.
|
||||
const digests = new Map<string, string>();
|
||||
for (const { path } of trace.images)
|
||||
digests.set(path, await fileSha256(join(root, path), options.signal));
|
||||
digests.set(
|
||||
path,
|
||||
await fileSha256(join(root, path), {
|
||||
path,
|
||||
expected: view.identity(path),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}),
|
||||
);
|
||||
const targetDigest =
|
||||
digests.get(target) ??
|
||||
(await fileSha256(join(root, target), options.signal));
|
||||
(await fileSha256(join(root, target), {
|
||||
path: target,
|
||||
expected: undefined,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}));
|
||||
if (targetDigest !== options.targetSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -170,23 +197,35 @@ const executableRoots = async (
|
||||
const compare = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
|
||||
/**
|
||||
* Resolve each root segment by segment, as dependency candidates are, so a
|
||||
* symlink in an intermediate directory cannot lead outside the analyzed root.
|
||||
*/
|
||||
const requireMachoRoots = async (
|
||||
view: FilesystemTreeView,
|
||||
roots: readonly string[],
|
||||
): Promise<void> => {
|
||||
): Promise<string[]> => {
|
||||
const resolved: string[] = [];
|
||||
for (const path of roots) {
|
||||
const entry = await view.entry(path);
|
||||
if (entry?.kind !== "file")
|
||||
const lookup = await resolveTreePath(view, path);
|
||||
if (lookup.kind === "escapes")
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Root ${path} resolves outside the analyzed root`,
|
||||
);
|
||||
if (lookup.kind !== "file")
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Root ${path} is not a regular file in the analyzed root`,
|
||||
);
|
||||
if ((await view.image(path)).status === "not-mach-o")
|
||||
if ((await view.image(lookup.path)).status === "not-mach-o")
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Root ${path} is not a Mach-O image`,
|
||||
);
|
||||
if (!resolved.includes(lookup.path)) resolved.push(lookup.path);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
|
||||
@@ -200,15 +239,34 @@ const startsWithMachoMagic = async (path: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
const identityOf = (metadata: {
|
||||
readonly dev: bigint;
|
||||
readonly ino: bigint;
|
||||
readonly size: bigint;
|
||||
readonly mtimeNs: bigint;
|
||||
readonly ctimeNs: bigint;
|
||||
}): FileIdentity =>
|
||||
[
|
||||
metadata.dev,
|
||||
metadata.ino,
|
||||
metadata.size,
|
||||
metadata.mtimeNs,
|
||||
metadata.ctimeNs,
|
||||
].join(":");
|
||||
|
||||
const readImage = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MachoImageFacts> => {
|
||||
): Promise<{
|
||||
readonly facts: MachoImageFacts;
|
||||
readonly identity: FileIdentity;
|
||||
}> => {
|
||||
cancelled(signal);
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
return await readMachoImage(async (offset, length) => {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
const size = Number(metadata.size);
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
cancelled(signal);
|
||||
const buffer = new Uint8Array(
|
||||
Math.max(0, Math.min(length, size - offset)),
|
||||
@@ -216,17 +274,38 @@ const readImage = async (
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
}, size);
|
||||
return { facts, identity: identityOf(metadata) };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** Hash a file, failing if it is not the file (or the version) that was parsed. */
|
||||
const fileSha256 = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
absolute: string,
|
||||
options: {
|
||||
readonly path: string;
|
||||
readonly expected: FileIdentity | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
): Promise<string> => {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const { signal } = options;
|
||||
const handle = await open(
|
||||
absolute,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const unchanged = async (): Promise<void> => {
|
||||
if (
|
||||
options.expected !== undefined &&
|
||||
identityOf(await handle.stat({ bigint: true })) !== options.expected
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${options.path} changed while its dyld load commands were traced`,
|
||||
);
|
||||
};
|
||||
try {
|
||||
await unchanged();
|
||||
const hash = createHash("sha256");
|
||||
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
|
||||
for (;;) {
|
||||
@@ -235,6 +314,7 @@ const fileSha256 = async (
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
await unchanged();
|
||||
return hash.digest("hex");
|
||||
} finally {
|
||||
await handle.close();
|
||||
|
||||
@@ -211,9 +211,10 @@ describe("Mach-O load command reader failures", () => {
|
||||
});
|
||||
|
||||
it("requires 8-byte command alignment in 64-bit images", async () => {
|
||||
// A command the reader does not decode; only its size is checked.
|
||||
const command = new Uint8Array(12);
|
||||
const view = new DataView(command.buffer);
|
||||
view.setUint32(0, LC.CODE_SIGNATURE, true);
|
||||
view.setUint32(0, 0x7f000001, true);
|
||||
view.setUint32(4, 12, true);
|
||||
expect(await read(machoImage({ commands: [command] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
@@ -257,6 +258,11 @@ describe("Mach-O load command reader failures", () => {
|
||||
status: "malformed",
|
||||
reason: "load command 0 is too short to hold a string offset",
|
||||
});
|
||||
view.setUint32(0, LC.CODE_SIGNATURE, true);
|
||||
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load command 0 is too short for LC_CODE_SIGNATURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
|
||||
|
||||
@@ -279,6 +279,12 @@ const decodeCommand = (
|
||||
return;
|
||||
}
|
||||
if (command === LC_CODE_SIGNATURE) {
|
||||
// linkedit_data_command: cmd, cmdsize, dataoff, datasize.
|
||||
if (body.byteLength < 16)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for LC_CODE_SIGNATURE`,
|
||||
);
|
||||
slice.code_signature_present = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -547,3 +547,48 @@ describe("lazily loaded dependencies", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional loads", () => {
|
||||
it("carries a conditional fallback's uncertainty to its dependents and reuses", async () => {
|
||||
const VENDOR = "Contents/Frameworks/libvendor.dylib";
|
||||
const OTHER = "Contents/Frameworks/libother.dylib";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["/opt/vendor/lib", "@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/libvendor.dylib"),
|
||||
dependency("@executable_path/../Frameworks/libother.dylib"),
|
||||
],
|
||||
}),
|
||||
[VENDOR]: parsed(
|
||||
slice({
|
||||
install_name: "@rpath/libvendor.dylib",
|
||||
dependencies: [dependency("@loader_path/libgone.dylib")],
|
||||
}),
|
||||
),
|
||||
[OTHER]: parsed(
|
||||
slice({ dependencies: [dependency("@rpath/libvendor.dylib")] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(edgeFor(trace, MAIN, "@rpath/libvendor.dylib")).toMatchObject({
|
||||
resolution: { status: "conditional", image: VENDOR },
|
||||
loader_conditional: false,
|
||||
});
|
||||
expect(edgeFor(trace, VENDOR, "@loader_path/libgone.dylib")).toMatchObject({
|
||||
resolution: { status: "unresolved" },
|
||||
loader_conditional: true,
|
||||
});
|
||||
expect(edgeFor(trace, OTHER, "@rpath/libvendor.dylib")).toMatchObject({
|
||||
candidates: [{ source: "already-loaded" }],
|
||||
resolution: { status: "conditional", image: VENDOR },
|
||||
loader_conditional: false,
|
||||
});
|
||||
expect(
|
||||
trace.findings.find(({ kind }) => kind === "required-load-unresolved")
|
||||
?.explanation,
|
||||
).toContain(`${VENDOR} loads only conditionally`);
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,11 @@ const edgeSchema = z.strictObject({
|
||||
image: z.string().nullable(),
|
||||
}),
|
||||
install_name_matches: z.boolean().nullable(),
|
||||
/**
|
||||
* The loader was reached through a conditional edge, so it might not load;
|
||||
* this edge describes what happens only if it does.
|
||||
*/
|
||||
loader_conditional: z.boolean(),
|
||||
});
|
||||
|
||||
const findingSchema = z.strictObject({
|
||||
@@ -179,6 +184,8 @@ interface LoadedImage {
|
||||
readonly slice: MachoSlice;
|
||||
/** Images from the process root to this image; the rpath stack, outermost first. */
|
||||
readonly chain: readonly string[];
|
||||
/** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */
|
||||
readonly conditional: boolean;
|
||||
}
|
||||
|
||||
interface ProcessContext {
|
||||
@@ -307,7 +314,8 @@ const resolveDependency = async (
|
||||
loader: string,
|
||||
context: ProcessContext,
|
||||
): Promise<Edge> => {
|
||||
const chain = context.loaded.get(loader)?.chain ?? [loader];
|
||||
const current = context.loaded.get(loader);
|
||||
const chain = current?.chain ?? [loader];
|
||||
const loaded = context.byInstallName.get(dependency.install_name);
|
||||
const candidates: Candidate[] = [];
|
||||
if (loaded !== undefined)
|
||||
@@ -326,7 +334,14 @@ const resolveDependency = async (
|
||||
// dyld stops at the first loadable candidate.
|
||||
if (candidate.outcome === "resolved") break;
|
||||
}
|
||||
const resolved = resolution(candidates);
|
||||
const searched = resolution(candidates);
|
||||
// Reusing an image that itself loads only conditionally is conditional too.
|
||||
const resolved: Edge["resolution"] =
|
||||
loaded !== undefined &&
|
||||
context.loaded.get(loaded)?.conditional === true &&
|
||||
searched.status === "resolved"
|
||||
? { ...searched, status: "conditional" }
|
||||
: searched;
|
||||
const image =
|
||||
resolved.image === null ? undefined : context.images.get(resolved.image);
|
||||
const slice =
|
||||
@@ -345,6 +360,7 @@ const resolveDependency = async (
|
||||
slice === undefined || slice.install_name === null
|
||||
? null
|
||||
: slice.install_name === dependency.install_name,
|
||||
loader_conditional: current?.conditional ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -358,7 +374,11 @@ const traceProcess = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<Edge[]> => {
|
||||
const edges: Edge[] = [];
|
||||
context.loaded.set(context.root, { slice: rootSlice, chain: [context.root] });
|
||||
context.loaded.set(context.root, {
|
||||
slice: rootSlice,
|
||||
chain: [context.root],
|
||||
conditional: false,
|
||||
});
|
||||
if (rootSlice.install_name !== null)
|
||||
context.byInstallName.set(rootSlice.install_name, context.root);
|
||||
const pending = [context.root];
|
||||
@@ -381,7 +401,13 @@ const traceProcess = async (
|
||||
? compatibleSlice(facts.slices, context.architecture)
|
||||
: undefined;
|
||||
if (slice === undefined) continue;
|
||||
context.loaded.set(image, { slice, chain: [...current.chain, image] });
|
||||
context.loaded.set(image, {
|
||||
slice,
|
||||
chain: [...current.chain, image],
|
||||
// dyld may load an unknown earlier candidate instead of this fallback.
|
||||
conditional:
|
||||
current.conditional || edge.resolution.status === "conditional",
|
||||
});
|
||||
if (
|
||||
slice.install_name !== null &&
|
||||
!context.byInstallName.has(slice.install_name)
|
||||
|
||||
@@ -72,7 +72,9 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
: {
|
||||
...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.`,
|
||||
explanation: edge.loader_conditional
|
||||
? `No candidate for ${edge.install_name} exists in the analyzed root. ${edge.loader} loads only conditionally; if it loads, dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`
|
||||
: `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.`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,7 +83,7 @@ export 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.",
|
||||
"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 and conditionality 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.",
|
||||
"Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.",
|
||||
|
||||
@@ -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 { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -127,7 +127,15 @@ describe("trace-dylib-resolution CLI", () => {
|
||||
(narrowed.json as { normalized_result: { roots: unknown } })
|
||||
.normalized_result.roots,
|
||||
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
|
||||
for (const root of ["../outside", "Contents/Info.plist"]) {
|
||||
// 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 of [
|
||||
"../outside",
|
||||
"Contents/Info.plist",
|
||||
"Contents/Linked/Tool",
|
||||
]) {
|
||||
const rejected = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
|
||||
Reference in new issue
Block a user