feat(apple): project macOS bundle anatomy in application graphs
project_apple_application_graph accepted only IPA inventories. It now also projects macOS .app directories and ZIP or DMG inventories that contain applications. It reports application roots, platforms, and every nested bundle with its path-convention role, layout, parent, Info.plist path, executable candidates, and signing paths: - roles: app extensions, XPC services (including inside frameworks), frameworks, login items, system and driver extensions, plug-ins, resource bundles, and helper or nested applications; - privileged helpers in Contents/Library/LaunchServices and launchd plists in Contents/Library/LaunchAgents or LaunchDaemons; - direct files of Contents/Helpers; - symlinks by path. Their targets are not inventoried, so versioned frameworks are described through their real Versions/<version>/ directories. AppleDouble sidecars (._* and __MACOSX/) are excluded from roles. A DMG whose contents could not be inventoried is reported as partial. Other inventory formats remain input errors. The projection still reads no plist or CMS semantics; it points callers at inspect_plist. The result schema is additive. projection_id now covers the new fields, so IPA projection IDs differ from earlier versions. verify:macos-bundle compiles a source-owned app with clang (CLT only). It packs it as a directory, a ditto ZIP, and an APFS DMG, and checks identical anatomy through the CLI with stdio MCP parity. It runs in the macOS CI job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b72c079a9a
commit
dd3e7aef0d
|
|
@ -302,6 +302,7 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run verify:keyed-archive
|
||||
- run: npm run verify:asset-catalog
|
||||
- run: npm run verify:macos-bundle
|
||||
- name: Native compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ Deep native binary analysis requires [Hopper](https://www.hopperapp.com/), [Ghid
|
|||
|
||||
Firmware region inspection and explicit extraction use caller-supplied Binwalk and Unblob on Linux. See [Firmware analysis](docs/firmware-analysis.md) for setup, provenance, resource limits and native handoff.
|
||||
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and a full JDK, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures. Authenticated IPA inventory Evidence can be projected with [Apple application analysis](docs/apple-application-analysis.md).
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and a full JDK, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures. Authenticated IPA and macOS `.app`, ZIP, or DMG inventory Evidence can be projected into bundle anatomy, such as XPC services, app extensions, login items, privileged helpers, and launchd plists, with [Apple application analysis](docs/apple-application-analysis.md).
|
||||
|
||||
Repository main and npm 4.1.0 include experimental Windows x64 Ghidra support for native x86-64 PE applications on local NTFS, with bundled Job Object, private-DACL, and path-admission controls. Check the [release boundary](docs/installation.md#released-package-and-main) before expecting this from an older npm package. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# Apple Application Inventory Projection
|
||||
|
||||
REA can project one or more authenticated `inventory_artifact` Evidence records
|
||||
for the same IPA into an Apple application inventory. The projection is
|
||||
execution-free: it reports exact paths, content hashes, detected formats,
|
||||
runtime-family hints, and path-based bridge hypotheses. It does not parse plist
|
||||
or CMS semantics or claim observed runtime calls.
|
||||
for the same IPA, macOS `.app` directory, ZIP, or DMG into an Apple application
|
||||
inventory. The projection is execution-free: it reports exact paths, content
|
||||
hashes, detected formats, bundle anatomy, runtime-family hints, and path-based
|
||||
bridge hypotheses. It does not parse plist or CMS semantics or claim observed
|
||||
runtime calls.
|
||||
|
||||
```sh
|
||||
rea project-apple-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||
|
|
@ -29,3 +30,72 @@ Coverage is `complete-within-inventory` when the supplied pages reconstruct the
|
|||
complete authenticated inventory, and `partial` otherwise. A partial result
|
||||
states that absence is unknown. Component and bridge-candidate arrays are still
|
||||
the complete projection of the inventory that was supplied.
|
||||
|
||||
## Application roots
|
||||
|
||||
| Inventory root | Application root |
|
||||
| -------------------------------------- | --------------------------------------------------- |
|
||||
| IPA | Each `Payload/<Name>.app` |
|
||||
| `.app` directory | `.`, the inventoried bundle itself |
|
||||
| Directory, ZIP, or DMG containing apps | Each outermost `<Name>.app` followed by `Contents/` |
|
||||
|
||||
ZIP archives made with `ditto -c -k --keepParent` report `<Name>.app`. A DMG
|
||||
reports `<image>.dmg/<Volume>/<Name>.app`; DMG child inventory needs the native
|
||||
macOS mounting adapter. When a DMG has only its root identity, the projection is
|
||||
`partial` and says that bundle absence is unknown. Other inventory formats, such
|
||||
as APK or ASAR, are rejected as input errors.
|
||||
|
||||
`platforms` lists `macos` for deep `Contents/` application roots and `ios` for
|
||||
shallow IPA application roots.
|
||||
|
||||
## macOS bundle anatomy
|
||||
|
||||
`bundles` lists each application root and every nested bundle under it. Each
|
||||
bundle reports its `parent_path`, `layout`, `info_plist_path`,
|
||||
`executable_candidates`, and `signing_paths`.
|
||||
|
||||
| Layout | Meaning | Content directory |
|
||||
| --------------------- | --------------- | --------------------------------------------- |
|
||||
| `macos-deep` | Has `Contents/` | `Contents/`, executables in `Contents/MacOS/` |
|
||||
| `versioned-framework` | Has `Versions/` | Each real `Versions/<version>/` |
|
||||
| `shallow` | Neither | The bundle directory itself |
|
||||
|
||||
Roles are path conventions (`role_basis: "path-convention"`):
|
||||
|
||||
| Role | Path |
|
||||
| -------------------- | --------------------------------------------------------------- |
|
||||
| `app-extension` | `*.appex` |
|
||||
| `xpc-service` | `*.xpc`, including XPC services inside frameworks |
|
||||
| `framework` | `*.framework` |
|
||||
| `system-extension` | `*.systemextension` |
|
||||
| `driver-extension` | `*.dext` |
|
||||
| `plug-in` | `PlugIns/*.bundle`, `*.plugin`, `*.qlgenerator`, `*.mdimporter` |
|
||||
| `resource-bundle` | `Resources/*.bundle`, such as SwiftPM resource bundles |
|
||||
| `bundle` | Any other `*.bundle` |
|
||||
| `login-item` | `Contents/Library/LoginItems/*.app` |
|
||||
| `helper-application` | `Contents/Helpers/*.app` |
|
||||
| `nested-application` | Any other nested `*.app` |
|
||||
|
||||
The components also list:
|
||||
|
||||
- `privileged_helpers`: files directly in `Contents/Library/LaunchServices/`,
|
||||
where SMJobBless helpers live;
|
||||
- `launchd_plists`: `.plist` files directly in `Contents/Library/LaunchAgents/`
|
||||
(`domain: "agent"`) or `Contents/Library/LaunchDaemons/` (`domain: "daemon"`),
|
||||
as used by SMAppService;
|
||||
- `helpers`: files directly in `Contents/Helpers/`.
|
||||
|
||||
Symlinks are reported by path in `symlinks`. Their targets are not inventoried,
|
||||
so a versioned framework is described through its real `Versions/<version>/`
|
||||
directories rather than `Versions/Current`. When a framework has zero or several
|
||||
`Versions/*/Resources/Info.plist` files, its `info_plist_path` is `null` and a
|
||||
limitation explains why.
|
||||
|
||||
AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they
|
||||
describe neighbouring files. They are excluded from roots, bundle roles, and the
|
||||
anatomy components.
|
||||
|
||||
The projection does not read plists. Use `inspect_plist` on a bundle's
|
||||
`info_plist_path` or a launchd plist to read `CFBundleExecutable`, identifiers,
|
||||
`SMPrivilegedExecutables`, `BundleProgram`, URL schemes, and usage descriptions.
|
||||
Use `inspect_signature` on an executable candidate for code-signing claims.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"client": "2.3.1",
|
||||
"core": "2.3.1"
|
||||
},
|
||||
"skill_version": "26"
|
||||
"skill_version": "27"
|
||||
},
|
||||
"tools": {
|
||||
"total": 131,
|
||||
|
|
@ -669,9 +669,9 @@
|
|||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "bfb106573792db139ad5c651705435818ec9dceb07aad8da6076836c8f77cc6b",
|
||||
"tools_sha256": "db85863d0b0947f21a14fcffe1e0e293842c68a209beb4125900d797f1242ccd",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "95ad932755e4fbcaed940d2e4f968563c63b77d9dcfbc5058c91dff3f844829e",
|
||||
"combined_sha256": "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9",
|
||||
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
|||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
|
||||
source-owned app with `clang`: a versioned framework, XPC services, an app
|
||||
extension, a login item, a privileged helper, launchd plists, and a helper tool,
|
||||
signed ad hoc. It packs the app as a directory, a `ditto` ZIP, and an APFS DMG,
|
||||
then checks that `inspect-artifact` plus `project-apple-application-graph`
|
||||
report the same bundle anatomy for all three through the CLI, with stdio MCP
|
||||
parity. It also checks that the DMG is detached afterwards. It runs in macOS CI.
|
||||
|
||||
MCP SDK transport tests with recording providers remain integration tests.
|
||||
They are useful for schema drift and failure projection but do not prove that
|
||||
|
|
@ -204,7 +211,7 @@ kept under ignored `_reference/`. No Gradle build, Android SDK, emulator or
|
|||
application execution is required. The lane compares real CLI/MCP package,
|
||||
class search, class inventory, method decompilation and incoming references.
|
||||
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
|
||||
Authenticated IPA inventory projection is documented in
|
||||
Authenticated IPA and macOS application inventory projection is documented in
|
||||
[Apple application analysis](apple-application-analysis.md).
|
||||
|
||||
The lane and owned-process cancellation helper live in `scripts/verify/android/`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_2e2c32ffd3a91da87fde91e199a2d3ce8a804aebd07c23dd4b5170ba40fe4bc7",
|
||||
"manifest_id": "ecm_7c7f2d7520a5b3ba8f4811c9ca9efcb4a7d9a7d7b68c57b396cc418c9cbea2ee",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "088de4627ea17da86dfffe32bd937df8601822deee124e36795112d380c14784"
|
||||
"sha256": "acc3d70d97ac210557d5e80c0cd84c236474b7162fddf67753c5a2518b66bb54"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"verify:apple-dispatch": "npm run build:cached && node scripts/verify-apple-dispatch.mjs",
|
||||
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify-keyed-archive.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify-macos-bundle.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ const environment = () => ({
|
|||
|
||||
/** Execute the compiled CLI with production providers and no test adapters. */
|
||||
export async function artifactCli(command, target, arguments_ = []) {
|
||||
return (await artifactCliEvidence(command, target, arguments_))
|
||||
.normalized_result;
|
||||
}
|
||||
|
||||
/** Execute the compiled CLI and return its complete Evidence record. */
|
||||
export async function artifactCliEvidence(command, target, arguments_ = []) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
|
|
@ -29,7 +35,7 @@ export async function artifactCli(command, target, arguments_ = []) {
|
|||
const evidence = JSON.parse(stdout);
|
||||
assert.equal(evidence.error, undefined, stdout);
|
||||
assert.ok(evidence.normalized_result, "CLI omitted normalized result");
|
||||
return evidence.normalized_result;
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/** Run a real stdio MCP subprocess, owning and closing its transport. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** Commands the macOS bundle fixture needs from Command Line Tools. */
|
||||
export const MACOS_BUNDLE_FIXTURE_COMMANDS = [
|
||||
["/usr/bin/xcrun", ["--find", "clang"]],
|
||||
["/usr/bin/xcrun", ["--find", "codesign"]],
|
||||
["/usr/bin/ditto", ["-h"]],
|
||||
["/usr/bin/hdiutil", ["help"]],
|
||||
];
|
||||
|
||||
/** Fail with the missing command name before building any fixture. */
|
||||
export async function preflightMacosBundleFixture() {
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
"macOS bundle verification requires a macOS host with Command Line Tools",
|
||||
);
|
||||
for (const [command, arguments_] of MACOS_BUNDLE_FIXTURE_COMMANDS)
|
||||
try {
|
||||
await exec(command, arguments_);
|
||||
} catch (cause) {
|
||||
// `ditto -h` and `hdiutil help` exit non-zero while printing usage.
|
||||
if (cause?.code === "ENOENT")
|
||||
throw new Error(
|
||||
`macOS bundle verification requires ${command} ${arguments_.join(" ")}`,
|
||||
{ cause },
|
||||
);
|
||||
if (command === "/usr/bin/xcrun")
|
||||
throw new Error(
|
||||
`macOS bundle verification requires Command Line Tools (${arguments_.join(" ")} failed)`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const plist = (entries) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
${Object.entries(entries)
|
||||
.map(([key, value]) => ` <key>${key}</key>\n <string>${value}</string>`)
|
||||
.join("\n")}
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
const bundlePlist = (identifier, executable, packageType = "APPL") =>
|
||||
plist({
|
||||
CFBundleIdentifier: identifier,
|
||||
CFBundleExecutable: executable,
|
||||
CFBundlePackageType: packageType,
|
||||
});
|
||||
|
||||
const write = async (path, text) => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, text);
|
||||
};
|
||||
|
||||
/** Compile one C source beneath `root/sources` into `output`. */
|
||||
const clang = async (root, { name, source, output, flags = [] }) => {
|
||||
const sourcePath = join(root, "sources", `${name}.c`);
|
||||
await write(sourcePath, source);
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
"-O0",
|
||||
sourcePath,
|
||||
"-o",
|
||||
output,
|
||||
...flags,
|
||||
]);
|
||||
};
|
||||
|
||||
const PROGRAM = `int main(void) { return 0; }\n`;
|
||||
|
||||
/** A versioned framework whose own rpath supplies a second `libchain.dylib`. */
|
||||
const buildCoreFramework = async (root, frameworks) => {
|
||||
const core = join(frameworks, "Core.framework");
|
||||
const version = join(core, "Versions", "A");
|
||||
const chainFlags = ["-dynamiclib", "-install_name", "@rpath/libchain.dylib"];
|
||||
await clang(root, {
|
||||
name: "chain-app",
|
||||
source: `const char *chain_variant(void) { return "app"; }\n`,
|
||||
output: join(frameworks, "libchain.dylib"),
|
||||
flags: chainFlags,
|
||||
});
|
||||
await clang(root, {
|
||||
name: "chain-framework",
|
||||
source: `const char *chain_variant(void) { return "framework"; }\n`,
|
||||
output: join(version, "Libraries", "libchain.dylib"),
|
||||
flags: chainFlags,
|
||||
});
|
||||
await clang(root, {
|
||||
name: "sibling",
|
||||
source: `int sibling_value(void) { return 7; }\n`,
|
||||
output: join(version, "libsibling.dylib"),
|
||||
flags: ["-dynamiclib", "-install_name", "@loader_path/libsibling.dylib"],
|
||||
});
|
||||
await clang(root, {
|
||||
name: "core",
|
||||
source: `const char *chain_variant(void);
|
||||
int sibling_value(void);
|
||||
const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : "broken"; }
|
||||
`,
|
||||
output: join(version, "Core"),
|
||||
flags: [
|
||||
"-dynamiclib",
|
||||
"-install_name",
|
||||
"@rpath/Core.framework/Versions/A/Core",
|
||||
"-Wl,-rpath,@loader_path/Libraries",
|
||||
join(version, "libsibling.dylib"),
|
||||
join(version, "Libraries", "libchain.dylib"),
|
||||
],
|
||||
});
|
||||
await write(
|
||||
join(version, "Resources", "Info.plist"),
|
||||
bundlePlist("com.example.rea.core", "Core", "FMWK"),
|
||||
);
|
||||
const service = join(version, "XPCServices", "FwSvc.xpc", "Contents");
|
||||
await clang(root, {
|
||||
name: "framework-service",
|
||||
source: PROGRAM,
|
||||
output: join(service, "MacOS", "FwSvc"),
|
||||
});
|
||||
await write(
|
||||
join(service, "Info.plist"),
|
||||
bundlePlist("com.example.rea.core.service", "FwSvc", "XPC!"),
|
||||
);
|
||||
await symlink("A", join(core, "Versions", "Current"));
|
||||
await symlink("Versions/Current/Core", join(core, "Core"));
|
||||
await symlink("Versions/Current/Resources", join(core, "Resources"));
|
||||
await symlink("Versions/Current/XPCServices", join(core, "XPCServices"));
|
||||
return join(version, "Core");
|
||||
};
|
||||
|
||||
/** The main executable, with a weak dependency deleted after linking. */
|
||||
const buildMainExecutable = async (root, contents, coreBinary) => {
|
||||
const gone = join(contents, "Frameworks", "libgone.dylib");
|
||||
await clang(root, {
|
||||
name: "gone",
|
||||
source: `int gone_value(void) { return 1; }\n`,
|
||||
output: gone,
|
||||
flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"],
|
||||
});
|
||||
const executable = join(contents, "MacOS", "MacFixture");
|
||||
await clang(root, {
|
||||
name: "main",
|
||||
source: `#include <stdio.h>
|
||||
const char *core_chain(void);
|
||||
extern int gone_value(void) __attribute__((weak_import));
|
||||
int main(void) {
|
||||
printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent");
|
||||
return 0;
|
||||
}
|
||||
`,
|
||||
output: executable,
|
||||
flags: [
|
||||
"-Wl,-rpath,@executable_path/../Frameworks",
|
||||
coreBinary,
|
||||
`-Wl,-weak_library,${gone}`,
|
||||
],
|
||||
});
|
||||
await rm(gone);
|
||||
await write(
|
||||
join(contents, "Info.plist"),
|
||||
bundlePlist("com.example.rea.fixture", "MacFixture"),
|
||||
);
|
||||
return executable;
|
||||
};
|
||||
|
||||
/** One `Contents/`-layout bundle with a trivial executable. */
|
||||
const buildNestedBundle = async (root, { bundle, executable, plist }) => {
|
||||
await clang(root, {
|
||||
name: executable,
|
||||
source: PROGRAM,
|
||||
output: join(bundle, "Contents", "MacOS", executable),
|
||||
});
|
||||
await write(join(bundle, "Contents", "Info.plist"), plist);
|
||||
};
|
||||
|
||||
const buildNestedBundles = async (root, contents, coreBinary) => {
|
||||
const service = join(contents, "XPCServices", "Svc.xpc", "Contents");
|
||||
await clang(root, {
|
||||
name: "service",
|
||||
source: `const char *core_chain(void);
|
||||
int main(void) { return core_chain()[0] == 'b'; }
|
||||
`,
|
||||
output: join(service, "MacOS", "Svc"),
|
||||
flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary],
|
||||
});
|
||||
await write(
|
||||
join(service, "Info.plist"),
|
||||
bundlePlist("com.example.rea.fixture.service", "Svc", "XPC!"),
|
||||
);
|
||||
await buildNestedBundle(root, {
|
||||
bundle: join(contents, "PlugIns", "Ext.appex"),
|
||||
executable: "Ext",
|
||||
plist: bundlePlist("com.example.rea.fixture.extension", "Ext", "XPC!"),
|
||||
});
|
||||
await buildNestedBundle(root, {
|
||||
bundle: join(contents, "Library", "LoginItems", "Login.app"),
|
||||
executable: "Login",
|
||||
plist: bundlePlist("com.example.rea.fixture.login", "Login"),
|
||||
});
|
||||
};
|
||||
|
||||
/** A privileged helper, its launchd plists, and a non-bundle helper tool. */
|
||||
const buildLaunchItems = async (root, contents) => {
|
||||
await clang(root, {
|
||||
name: "privileged-helper",
|
||||
source: PROGRAM,
|
||||
output: join(
|
||||
contents,
|
||||
"Library",
|
||||
"LaunchServices",
|
||||
"com.example.rea.helper",
|
||||
),
|
||||
});
|
||||
await write(
|
||||
join(contents, "Library", "LaunchDaemons", "com.example.rea.helper.plist"),
|
||||
plist({
|
||||
Label: "com.example.rea.helper",
|
||||
BundleProgram: "Contents/Library/LaunchServices/com.example.rea.helper",
|
||||
}),
|
||||
);
|
||||
await write(
|
||||
join(contents, "Library", "LaunchAgents", "com.example.rea.agent.plist"),
|
||||
plist({
|
||||
Label: "com.example.rea.agent",
|
||||
BundleProgram: "Contents/Helpers/rea-tool",
|
||||
}),
|
||||
);
|
||||
await clang(root, {
|
||||
name: "tool",
|
||||
source: PROGRAM,
|
||||
output: join(contents, "Helpers", "rea-tool"),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build `MacFixture.app` with every bundle role and dylib-loading shape REA
|
||||
* projects. The main executable prints which `libchain.dylib` copy dyld
|
||||
* selected, so callers can compare static resolution with a real load.
|
||||
*/
|
||||
export async function buildMacosBundleFixture(root) {
|
||||
const app = join(root, "MacFixture.app");
|
||||
const contents = join(app, "Contents");
|
||||
const coreBinary = await buildCoreFramework(
|
||||
root,
|
||||
join(contents, "Frameworks"),
|
||||
);
|
||||
const executable = await buildMainExecutable(root, contents, coreBinary);
|
||||
await buildNestedBundles(root, contents, coreBinary);
|
||||
await buildLaunchItems(root, contents);
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"codesign",
|
||||
"--force",
|
||||
"--deep",
|
||||
"--sign",
|
||||
"-",
|
||||
app,
|
||||
]);
|
||||
return { app, executable };
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCliEvidence,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "./lib/macos-bundle-fixture.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
await preflightMacosBundleFixture();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-macos-bundle-"));
|
||||
|
||||
/** Bundle roles relative to the application root, independent of the container. */
|
||||
const anatomy = (projection, applicationRoot) => {
|
||||
const relative = (path) =>
|
||||
applicationRoot === "."
|
||||
? path
|
||||
: path === applicationRoot
|
||||
? "."
|
||||
: path.slice(applicationRoot.length + 1);
|
||||
const paths = (components) =>
|
||||
components.map(({ path }) => relative(path)).sort();
|
||||
return {
|
||||
bundles: projection.bundles.map((bundle) => ({
|
||||
path: relative(bundle.path),
|
||||
role: bundle.role,
|
||||
layout: bundle.layout,
|
||||
parent: bundle.parent_path === null ? null : relative(bundle.parent_path),
|
||||
info_plist: bundle.info_plist_path && relative(bundle.info_plist_path),
|
||||
executables: bundle.executable_candidates.map(relative),
|
||||
signing: bundle.signing_paths.map(relative),
|
||||
})),
|
||||
privileged_helpers: paths(projection.components.privileged_helpers),
|
||||
launchd_plists: projection.components.launchd_plists
|
||||
.map(({ path, domain }) => `${domain}:${relative(path)}`)
|
||||
.sort(),
|
||||
helpers: paths(projection.components.helpers),
|
||||
};
|
||||
};
|
||||
|
||||
const FRAMEWORK = "Contents/Frameworks/Core.framework";
|
||||
const EXPECTED_BUNDLES = [
|
||||
[".", "application", "macos-deep", null],
|
||||
[FRAMEWORK, "framework", "versioned-framework", "."],
|
||||
[
|
||||
`${FRAMEWORK}/Versions/A/XPCServices/FwSvc.xpc`,
|
||||
"xpc-service",
|
||||
"macos-deep",
|
||||
FRAMEWORK,
|
||||
],
|
||||
["Contents/Library/LoginItems/Login.app", "login-item", "macos-deep", "."],
|
||||
["Contents/PlugIns/Ext.appex", "app-extension", "macos-deep", "."],
|
||||
["Contents/XPCServices/Svc.xpc", "xpc-service", "macos-deep", "."],
|
||||
];
|
||||
|
||||
try {
|
||||
const { app } = await buildMacosBundleFixture(root);
|
||||
const zip = join(root, "MacFixture.zip");
|
||||
await exec("/usr/bin/ditto", ["-c", "-k", "--keepParent", app, zip]);
|
||||
const staging = join(root, "dmg-source");
|
||||
await mkdir(staging);
|
||||
await exec("/usr/bin/ditto", [app, join(staging, "MacFixture.app")]);
|
||||
await symlink("/Applications", join(staging, "Applications"));
|
||||
const dmg = join(root, "MacFixture.dmg");
|
||||
// The default APFS image attaches as an image disk plus a synthesized
|
||||
// container, which exercises detach of devices sharing one image.
|
||||
await exec("/usr/bin/hdiutil", [
|
||||
"create",
|
||||
"-quiet",
|
||||
"-srcfolder",
|
||||
staging,
|
||||
"-volname",
|
||||
"ReaFixture",
|
||||
"-format",
|
||||
"UDZO",
|
||||
dmg,
|
||||
]);
|
||||
|
||||
const containers = [
|
||||
{ target: app, format: "directory", applicationRoot: "." },
|
||||
{ target: zip, format: "zip", applicationRoot: "MacFixture.app" },
|
||||
{
|
||||
target: dmg,
|
||||
format: "dmg",
|
||||
applicationRoot: "MacFixture.dmg/ReaFixture/MacFixture.app",
|
||||
},
|
||||
];
|
||||
const anatomies = [];
|
||||
for (const { target, format, applicationRoot } of containers) {
|
||||
const inventory = await artifactCliEvidence("inspect-artifact", target);
|
||||
const input = join(root, `${format}-projection.json`);
|
||||
await writeFile(input, JSON.stringify({ inventory_evidence: [inventory] }));
|
||||
const evidence = await artifactCliEvidence(
|
||||
"project-apple-application-graph",
|
||||
input,
|
||||
);
|
||||
const projection = evidence.normalized_result;
|
||||
assert.equal(evidence.subject?.format, format);
|
||||
assert.equal(projection.root_format, format);
|
||||
assert.deepEqual(projection.platforms, ["macos"]);
|
||||
assert.deepEqual(projection.application_roots, [applicationRoot]);
|
||||
assert.equal(projection.coverage.status, "complete-within-inventory");
|
||||
const observed = anatomy(projection, applicationRoot);
|
||||
assert.deepEqual(
|
||||
observed.bundles.map(({ path, role, layout, parent }) => [
|
||||
path,
|
||||
role,
|
||||
layout,
|
||||
parent,
|
||||
]),
|
||||
EXPECTED_BUNDLES,
|
||||
`${format} bundle roles drifted`,
|
||||
);
|
||||
assert.deepEqual(observed.privileged_helpers, [
|
||||
"Contents/Library/LaunchServices/com.example.rea.helper",
|
||||
]);
|
||||
assert.deepEqual(observed.launchd_plists, [
|
||||
"agent:Contents/Library/LaunchAgents/com.example.rea.agent.plist",
|
||||
"daemon:Contents/Library/LaunchDaemons/com.example.rea.helper.plist",
|
||||
]);
|
||||
assert.deepEqual(observed.helpers, ["Contents/Helpers/rea-tool"]);
|
||||
assert.ok(
|
||||
projection.symlinks.some((path) =>
|
||||
path.endsWith(`${FRAMEWORK}/Versions/Current`),
|
||||
),
|
||||
"Versions/Current symlink was not reported",
|
||||
);
|
||||
anatomies.push(observed);
|
||||
if (format === "directory")
|
||||
await withArtifactMcp(app, async (client) => {
|
||||
assert.deepEqual(
|
||||
await artifactMcpResult(client, "project_apple_application_graph", {
|
||||
inventory_evidence: [inventory],
|
||||
}),
|
||||
projection,
|
||||
);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(anatomies[1], anatomies[0], "ZIP anatomy differs");
|
||||
assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs");
|
||||
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
|
||||
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
containers: containers.map(({ format }) => format),
|
||||
bundles: EXPECTED_BUNDLES.length,
|
||||
dmg_detached: true,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "26"
|
||||
version: "27"
|
||||
tool_count: 131
|
||||
catalog_digest: "95ad932755e4fbcaed940d2e4f968563c63b77d9dcfbc5058c91dff3f844829e"
|
||||
catalog_digest: "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ ASAR, or DMG inputs when the artifact graph and findings help answer the
|
|||
question. It returns the complete artifact graph inline. Cite graph manifest
|
||||
IDs when using them.
|
||||
|
||||
For an IPA or a macOS `.app`, ZIP, or DMG, pass the inventory Evidence to
|
||||
`project_apple_application_graph`. It reports application roots and nested
|
||||
bundles by path convention: app extensions, XPC services, frameworks, login
|
||||
items, system and driver extensions, and plug-ins. It also lists privileged
|
||||
helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and
|
||||
executable candidates. Roles are path conventions, not parsed plists; read the
|
||||
listed plists with `inspect_plist`.
|
||||
|
||||
Use `extract_artifact` when materialized files are needed. It takes no arguments
|
||||
and materializes all regular files into a fresh temporary directory chosen by
|
||||
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { projectInventoryEvidence } from "./InventoryProjectionEvidence.js";
|
|||
|
||||
const OPERATION = "project_apple_application_graph" as const;
|
||||
|
||||
/** Project authenticated IPA inventory Evidence into Apple application evidence. */
|
||||
/** Project authenticated IPA or macOS app inventory Evidence into Apple application evidence. */
|
||||
export const projectAppleApplicationEvidence = (
|
||||
rawInput: unknown,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
|
|
@ -21,7 +21,7 @@ export const projectAppleApplicationEvidence = (
|
|||
operation: OPERATION,
|
||||
predicateType: "rea.apple-application-graph",
|
||||
provider: APPLE_APPLICATION_PROVIDER,
|
||||
subjectFormat: () => "ipa",
|
||||
subjectFormat: (first) => first.subject?.format ?? "unknown",
|
||||
protocolError: "Apple application projection produced an invalid result",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const registerApplicationCommands = (
|
|||
logger,
|
||||
name: CLI_COMMANDS.projectAppleApplicationGraph,
|
||||
description:
|
||||
"Project authenticated IPA inventory Evidence into an Apple application graph",
|
||||
"Project authenticated IPA or macOS app inventory Evidence into an Apple application graph",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
workflow: (input) => {
|
||||
const result = projectAppleApplicationEvidence(input);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import {
|
||||
ANDROID_APPLICATION_GRAPH_EXAMPLE,
|
||||
APPLE_APPLICATION_GRAPH_EXAMPLE,
|
||||
MACOS_APPLICATION_GRAPH_EXAMPLE,
|
||||
} from "./mobileApplicationGraphExamples.js";
|
||||
|
||||
const traceOutputSchema = evidenceResultOf(applicationFeatureTraceResultSchema);
|
||||
|
|
@ -278,7 +279,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
|||
name: "project_apple_application_graph",
|
||||
...toolContractMetadata("project_apple_application_graph"),
|
||||
description:
|
||||
"Project authenticated IPA inventory_artifact Evidence into an execution-free Apple application inventory. Reports exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without parsing plist/CMS semantics or claiming observed runtime calls.",
|
||||
"Project authenticated IPA, macOS .app directory, ZIP, or DMG inventory Evidence into an execution-free Apple application inventory. Reports application roots, nested bundles with path-convention roles (app extensions, XPC services, login items, privileged helpers, system extensions, frameworks), launchd plists, exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without parsing plist/CMS semantics or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(appleApplicationProjectionResultSchema),
|
||||
|
|
@ -287,6 +288,11 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
|||
title: "Project IPA inventory Evidence into an Apple application graph",
|
||||
input: APPLE_APPLICATION_GRAPH_EXAMPLE,
|
||||
},
|
||||
{
|
||||
title:
|
||||
"Project a macOS .app directory inventory into its bundle anatomy",
|
||||
input: MACOS_APPLICATION_GRAPH_EXAMPLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { canonicalDigest } from "../domain/comparisonSemantics.js";
|
|||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const inventory = (format: "apk" | "ipa", digit: string) => {
|
||||
const inventory = (format: "apk" | "ipa" | "directory", digit: string) => {
|
||||
const sha = digit.repeat(64);
|
||||
const artifactId = `art_${canonicalDigest({ sha256: sha }, "Artifact inventory")}`;
|
||||
const occurrenceId = `occ_${canonicalDigest(
|
||||
|
|
@ -112,3 +112,20 @@ export const APPLE_APPLICATION_GRAPH_EXAMPLE = {
|
|||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Catalog example for a macOS application bundle inventoried as a directory. */
|
||||
export const MACOS_APPLICATION_GRAPH_EXAMPLE = {
|
||||
inventory_evidence: [
|
||||
createEvidence(
|
||||
{ path: "Fixture.app", sha256: "c".repeat(64), format: "directory" },
|
||||
provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: inventory("directory", "c"),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectAppleApplication } from "./appleApplication.js";
|
||||
import { canonicalDigest } from "./comparisonSemantics.js";
|
||||
import { createEvidence, type Evidence } from "./evidence.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
type EntryKind = "file" | "directory" | "symlink";
|
||||
type RootFormat = "ipa" | "zip" | "dmg" | "directory" | "asar";
|
||||
|
||||
interface FixtureEntry {
|
||||
readonly path: string;
|
||||
readonly kind?: EntryKind;
|
||||
readonly format?: string;
|
||||
}
|
||||
|
||||
const sha = (text: string): string =>
|
||||
createHash("sha256").update(text).digest("hex");
|
||||
const artifactId = (sha256: string): string =>
|
||||
`art_${canonicalDigest({ sha256 }, "Artifact inventory")}`;
|
||||
|
||||
/** Build one complete, content-addressed inventory page for pure projection tests. */
|
||||
const inventoryEvidence = (
|
||||
rootFormat: RootFormat,
|
||||
subjectName: string,
|
||||
entries: readonly FixtureEntry[],
|
||||
): Evidence => {
|
||||
const rootSha = sha(`${rootFormat}:${subjectName}`);
|
||||
const rootId = artifactId(rootSha);
|
||||
const node = (sha256: string, format: string) => ({
|
||||
artifact_id: artifactId(sha256),
|
||||
kind: "resource",
|
||||
format,
|
||||
sha256,
|
||||
size: 1,
|
||||
media_type: null,
|
||||
architecture: null,
|
||||
executable: false,
|
||||
content_state: "materialized",
|
||||
limitations: [],
|
||||
});
|
||||
const occurrence = (path: string, kind: EntryKind, id: string | null) => ({
|
||||
occurrence_id:
|
||||
path === "."
|
||||
? `occ_${canonicalDigest({ root: rootId }, "Artifact inventory")}`
|
||||
: `occ_${canonicalDigest(
|
||||
{ root_artifact_id: rootId, logical_path: path, entry_kind: kind },
|
||||
"Artifact inventory",
|
||||
)}`,
|
||||
artifact_id: id,
|
||||
parent_occurrence_id: null,
|
||||
logical_path: path,
|
||||
entry_kind: kind,
|
||||
declared_size: null,
|
||||
compressed_size: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
hash_status: "verified",
|
||||
source_location: null,
|
||||
limitations: [],
|
||||
});
|
||||
const nodes = new Map([[rootId, node(rootSha, rootFormat)]]);
|
||||
const occurrences = [occurrence(".", "file", rootId)];
|
||||
for (const { path, kind = "file", format = "file" } of entries) {
|
||||
if (kind === "symlink") {
|
||||
occurrences.push(occurrence(path, kind, null));
|
||||
continue;
|
||||
}
|
||||
const contentSha = sha(`${kind}:${path}`);
|
||||
const id = artifactId(contentSha);
|
||||
nodes.set(
|
||||
id,
|
||||
node(contentSha, kind === "directory" ? "directory" : format),
|
||||
);
|
||||
occurrences.push(occurrence(path, kind, id));
|
||||
}
|
||||
const sortedNodes = [...nodes.values()].sort((left, right) =>
|
||||
left.artifact_id.localeCompare(right.artifact_id),
|
||||
);
|
||||
const sortedOccurrences = occurrences.sort((left, right) =>
|
||||
left.logical_path.localeCompare(right.logical_path, "en"),
|
||||
);
|
||||
const graphSha256 = canonicalDigest(
|
||||
{
|
||||
nodes: sortedNodes,
|
||||
occurrences: sortedOccurrences,
|
||||
edges: [],
|
||||
integrity_contradictions: [],
|
||||
},
|
||||
"Artifact inventory",
|
||||
);
|
||||
return createEvidence(
|
||||
{
|
||||
path: `/fixtures/${subjectName}`,
|
||||
sha256: rootSha,
|
||||
format: rootFormat,
|
||||
},
|
||||
{ id: "rea-artifact-graph", name: "fixture", version: "1" },
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
result: jsonValueSchema.parse({
|
||||
manifest: {
|
||||
manifest_id: `agm_${canonicalDigest(
|
||||
{ root_artifact_id: rootId, graph_sha256: graphSha256 },
|
||||
"Artifact inventory",
|
||||
)}`,
|
||||
root_artifact_id: rootId,
|
||||
root_sha256: rootSha,
|
||||
root_format: rootFormat,
|
||||
graph_sha256: graphSha256,
|
||||
node_count: sortedNodes.length,
|
||||
occurrence_count: sortedOccurrences.length,
|
||||
edge_count: 0,
|
||||
},
|
||||
nodes: sortedNodes,
|
||||
occurrences: sortedOccurrences,
|
||||
edges: [],
|
||||
provenance: [],
|
||||
limitations: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const project = (evidence: Evidence) =>
|
||||
projectAppleApplication({ inventory_evidence: [evidence] });
|
||||
|
||||
const macho = (path: string): FixtureEntry => ({ path, format: "mach-o" });
|
||||
|
||||
describe("macOS application roots and bundle roles", () => {
|
||||
it("treats an inventoried .app directory as its own application root", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("directory", "Fixture.app", [
|
||||
{ path: "Contents/Info.plist" },
|
||||
macho("Contents/MacOS/Fixture"),
|
||||
{ path: "Contents/_CodeSignature/CodeResources" },
|
||||
{ path: "Contents/embedded.provisionprofile" },
|
||||
macho("Contents/Library/LaunchServices/com.example.helper"),
|
||||
{ path: "Contents/Library/LaunchDaemons/com.example.helper.plist" },
|
||||
{ path: "Contents/Library/LaunchAgents/com.example.agent.plist" },
|
||||
macho("Contents/Helpers/tool"),
|
||||
macho("Contents/Helpers/Assistant.app/Contents/MacOS/Assistant"),
|
||||
macho(
|
||||
"Contents/Library/SystemExtensions/com.example.filter.systemextension/Contents/MacOS/com.example.filter",
|
||||
),
|
||||
macho(
|
||||
"Contents/Library/SystemExtensions/com.example.driver.dext/driver",
|
||||
),
|
||||
{ path: "Contents/PlugIns/Preview.qlgenerator", kind: "directory" },
|
||||
macho("Contents/PlugIns/Bridge.bundle/Contents/MacOS/Bridge"),
|
||||
{ path: "Contents/Resources/Strings.bundle/Info.plist" },
|
||||
{ path: "Contents/SharedSupport/Data.bundle", kind: "directory" },
|
||||
macho("Contents/Resources/Embedded.app/Contents/MacOS/Embedded"),
|
||||
]),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
root_format: "directory",
|
||||
platforms: ["macos"],
|
||||
application_roots: ["."],
|
||||
});
|
||||
expect(
|
||||
result.bundles.map(({ path, role, layout, parent_path: parent }) => ({
|
||||
path,
|
||||
role,
|
||||
layout,
|
||||
parent,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: ".", role: "application", layout: "macos-deep", parent: null },
|
||||
{
|
||||
path: "Contents/Helpers/Assistant.app",
|
||||
role: "helper-application",
|
||||
layout: "macos-deep",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/Library/SystemExtensions/com.example.driver.dext",
|
||||
role: "driver-extension",
|
||||
layout: "shallow",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/Library/SystemExtensions/com.example.filter.systemextension",
|
||||
role: "system-extension",
|
||||
layout: "macos-deep",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/PlugIns/Bridge.bundle",
|
||||
role: "plug-in",
|
||||
layout: "macos-deep",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/PlugIns/Preview.qlgenerator",
|
||||
role: "plug-in",
|
||||
layout: "shallow",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/Resources/Embedded.app",
|
||||
role: "nested-application",
|
||||
layout: "macos-deep",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/Resources/Strings.bundle",
|
||||
role: "resource-bundle",
|
||||
layout: "shallow",
|
||||
parent: ".",
|
||||
},
|
||||
{
|
||||
path: "Contents/SharedSupport/Data.bundle",
|
||||
role: "bundle",
|
||||
layout: "shallow",
|
||||
parent: ".",
|
||||
},
|
||||
]);
|
||||
expect(result.bundles[0]).toMatchObject({
|
||||
info_plist_path: "Contents/Info.plist",
|
||||
executable_candidates: ["Contents/MacOS/Fixture"],
|
||||
signing_paths: [
|
||||
"Contents/_CodeSignature/CodeResources",
|
||||
"Contents/embedded.provisionprofile",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
result.components.privileged_helpers.map(({ path }) => path),
|
||||
).toEqual(["Contents/Library/LaunchServices/com.example.helper"]);
|
||||
expect(
|
||||
result.components.launchd_plists.map(({ path, domain }) => [
|
||||
path,
|
||||
domain,
|
||||
]),
|
||||
).toEqual([
|
||||
["Contents/Library/LaunchAgents/com.example.agent.plist", "agent"],
|
||||
["Contents/Library/LaunchDaemons/com.example.helper.plist", "daemon"],
|
||||
]);
|
||||
expect(result.components.helpers.map(({ path }) => path)).toEqual([
|
||||
"Contents/Helpers/tool",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not make a non-application bundle directory an application root", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("directory", "Service.xpc", [
|
||||
{ path: "Contents/Info.plist" },
|
||||
macho("Contents/MacOS/Service"),
|
||||
]),
|
||||
);
|
||||
expect(result.application_roots).toEqual([]);
|
||||
expect(result.bundles).toEqual([]);
|
||||
expect(result.limitations).toContain(
|
||||
"No application bundle (Payload/*.app or *.app/Contents) was present in the supplied inventory pages.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple bundle layouts and containers", () => {
|
||||
it("follows versioned frameworks without resolving Versions/Current", () => {
|
||||
const framework = "Fixture.app/Contents/Frameworks/Core.framework";
|
||||
const result = project(
|
||||
inventoryEvidence("zip", "Fixture.zip", [
|
||||
macho("Fixture.app/Contents/MacOS/Fixture"),
|
||||
{ path: `${framework}/Core`, kind: "symlink" },
|
||||
{ path: `${framework}/Versions/Current`, kind: "symlink" },
|
||||
macho(`${framework}/Versions/A/Core`),
|
||||
{ path: `${framework}/Versions/A/Resources/Info.plist` },
|
||||
{ path: `${framework}/Versions/A/_CodeSignature/CodeResources` },
|
||||
macho(
|
||||
`${framework}/Versions/A/XPCServices/Fetch.xpc/Contents/MacOS/Fetch`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const core = result.bundles.find(({ path }) => path === framework);
|
||||
expect(core).toEqual({
|
||||
path: framework,
|
||||
parent_path: "Fixture.app",
|
||||
layout: "versioned-framework",
|
||||
role: "framework",
|
||||
role_basis: "path-convention",
|
||||
info_plist_path: `${framework}/Versions/A/Resources/Info.plist`,
|
||||
executable_candidates: [`${framework}/Versions/A/Core`],
|
||||
signing_paths: [`${framework}/Versions/A/_CodeSignature/CodeResources`],
|
||||
});
|
||||
expect(
|
||||
result.bundles.find(({ role }) => role === "xpc-service")?.parent_path,
|
||||
).toBe(framework);
|
||||
expect(result.symlinks).toEqual([
|
||||
`${framework}/Core`,
|
||||
`${framework}/Versions/Current`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an unknown Info.plist when several framework versions exist", () => {
|
||||
const framework = "Fixture.app/Contents/Frameworks/Core.framework";
|
||||
const result = project(
|
||||
inventoryEvidence("zip", "Fixture.zip", [
|
||||
macho("Fixture.app/Contents/MacOS/Fixture"),
|
||||
{ path: `${framework}/Versions/A/Resources/Info.plist` },
|
||||
{ path: `${framework}/Versions/B/Resources/Info.plist` },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.bundles.find(({ path }) => path === framework)?.info_plist_path,
|
||||
).toBeNull();
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Versions/Current selects is unknown"),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes AppleDouble sidecars from roots and roles", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("zip", "Fixture.zip", [
|
||||
macho("Fixture.app/Contents/MacOS/Fixture"),
|
||||
{ path: "Fixture.app/Contents/Library/LaunchDaemons/._helper.plist" },
|
||||
{ path: "Fixture.app/Contents/Library/LaunchDaemons/helper.plist" },
|
||||
{ path: "__MACOSX/Other.app/Contents/._Info.plist" },
|
||||
]),
|
||||
);
|
||||
expect(result.application_roots).toEqual(["Fixture.app"]);
|
||||
expect(result.components.launchd_plists.map(({ path }) => path)).toEqual([
|
||||
"Fixture.app/Contents/Library/LaunchDaemons/helper.plist",
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("AppleDouble sidecar entries"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a DMG without inventoried contents as partial", () => {
|
||||
const result = project(inventoryEvidence("dmg", "Fixture.dmg", []));
|
||||
expect(result.coverage).toEqual({
|
||||
status: "partial",
|
||||
inventory_complete: true,
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"The DMG's contents were not inventoried on this host; bundle absence is unknown.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps IPA applications shallow and iOS", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist" },
|
||||
macho("Payload/Fixture.app/Fixture"),
|
||||
{ path: "Payload/Fixture.app/embedded.mobileprovision" },
|
||||
macho("Payload/Fixture.app/PlugIns/Share.appex/Share"),
|
||||
]),
|
||||
);
|
||||
expect(result.platforms).toEqual(["ios"]);
|
||||
expect(
|
||||
result.bundles.map(({ path, role, layout }) => [path, role, layout]),
|
||||
).toEqual([
|
||||
["Payload/Fixture.app", "application", "shallow"],
|
||||
["Payload/Fixture.app/PlugIns/Share.appex", "app-extension", "shallow"],
|
||||
]);
|
||||
expect(result.bundles[0]?.executable_candidates).toEqual([
|
||||
"Payload/Fixture.app/Fixture",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects inventories that cannot contain an Apple application", () => {
|
||||
expect(() => project(inventoryEvidence("asar", "app.asar", []))).toThrow(
|
||||
"Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got asar)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,17 @@ import { createHash } from "node:crypto";
|
|||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
appleBundleSchema,
|
||||
appleComponentSchema,
|
||||
applicationRoots,
|
||||
detectBundles,
|
||||
isMachOFormat,
|
||||
isSidecar,
|
||||
isWithin,
|
||||
platformsOf,
|
||||
type AppleInventoryEntry,
|
||||
} from "./appleBundleAnatomy.js";
|
||||
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import { digestSchema } from "./../domain/digests.js";
|
||||
|
|
@ -11,12 +22,7 @@ import { prefixedDigestSchema } from "./../domain/digests.js";
|
|||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const pathSchema = z.string().min(1);
|
||||
|
||||
const componentSchema = z.strictObject({
|
||||
path: pathSchema,
|
||||
artifact_id: prefixedDigestSchema("art"),
|
||||
sha256: digestSchema,
|
||||
format: z.string().min(1),
|
||||
});
|
||||
const APPLE_ROOT_FORMATS = ["ipa", "directory", "zip", "dmg"] as const;
|
||||
|
||||
/** Authenticated artifact inventory pages projected as one Apple application. */
|
||||
export const appleApplicationProjectionInputSchema = z.strictObject({
|
||||
|
|
@ -27,17 +33,25 @@ export const appleApplicationProjectionInputSchema = z.strictObject({
|
|||
export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
projection_id: prefixedDigestSchema("aap"),
|
||||
root_sha256: digestSchema,
|
||||
root_format: z.literal("ipa"),
|
||||
root_format: z.enum(APPLE_ROOT_FORMATS),
|
||||
platforms: z.array(z.enum(["ios", "macos"])),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
application_roots: z.array(pathSchema),
|
||||
bundles: z.array(appleBundleSchema),
|
||||
components: z.strictObject({
|
||||
bundle_metadata: z.array(componentSchema),
|
||||
executables: z.array(componentSchema),
|
||||
frameworks: z.array(componentSchema),
|
||||
native_libraries: z.array(componentSchema),
|
||||
javascript: z.array(componentSchema),
|
||||
signing: z.array(componentSchema),
|
||||
bundle_metadata: z.array(appleComponentSchema),
|
||||
executables: z.array(appleComponentSchema),
|
||||
frameworks: z.array(appleComponentSchema),
|
||||
native_libraries: z.array(appleComponentSchema),
|
||||
javascript: z.array(appleComponentSchema),
|
||||
signing: z.array(appleComponentSchema),
|
||||
privileged_helpers: z.array(appleComponentSchema),
|
||||
launchd_plists: z.array(
|
||||
appleComponentSchema.extend({ domain: z.enum(["agent", "daemon"]) }),
|
||||
),
|
||||
helpers: z.array(appleComponentSchema),
|
||||
}),
|
||||
symlinks: z.array(pathSchema),
|
||||
runtime_families: z.array(
|
||||
z.enum([
|
||||
"native",
|
||||
|
|
@ -74,9 +88,10 @@ export type AppleApplicationProjectionResult = z.infer<
|
|||
typeof appleApplicationProjectionResultSchema
|
||||
>;
|
||||
|
||||
type Component = z.infer<typeof componentSchema>;
|
||||
type Component = z.infer<typeof appleComponentSchema>;
|
||||
type RootFormat = (typeof APPLE_ROOT_FORMATS)[number];
|
||||
|
||||
/** Project exact IPA paths and hashes without parsing or executing target code. */
|
||||
/** Project exact Apple application paths and hashes without parsing or executing target code. */
|
||||
export const projectAppleApplication = (
|
||||
input: AppleApplicationProjectionInput,
|
||||
): AppleApplicationProjectionResult => {
|
||||
|
|
@ -84,38 +99,42 @@ export const projectAppleApplication = (
|
|||
const { evidence, inventory } = parseArtifactInventoryEvidence(
|
||||
parsed.inventory_evidence,
|
||||
);
|
||||
if (inventory.manifest.root_format !== "ipa")
|
||||
throw new TypeError("Apple application projection requires IPA Evidence");
|
||||
const rootFormat = appleRootFormat(inventory.manifest.root_format);
|
||||
const nodes = new Map(
|
||||
inventory.nodes.map((node) => [node.artifact_id, node]),
|
||||
);
|
||||
const all = inventory.occurrences
|
||||
.filter(
|
||||
(occurrence) =>
|
||||
occurrence.artifact_id !== null && occurrence.logical_path !== ".",
|
||||
)
|
||||
const entries: AppleInventoryEntry[] = inventory.occurrences
|
||||
.filter(({ logical_path: path }) => path !== ".")
|
||||
.map((occurrence) => {
|
||||
const node = nodes.get(occurrence.artifact_id ?? "");
|
||||
if (occurrence.artifact_id === null)
|
||||
return {
|
||||
path: occurrence.logical_path,
|
||||
kind: occurrence.entry_kind,
|
||||
component: undefined,
|
||||
};
|
||||
const node = nodes.get(occurrence.artifact_id);
|
||||
if (node === undefined)
|
||||
throw new TypeError(
|
||||
"Apple application occurrence has no artifact node",
|
||||
);
|
||||
return {
|
||||
path: occurrence.logical_path,
|
||||
artifact_id: node.artifact_id,
|
||||
sha256: node.sha256,
|
||||
format: node.format,
|
||||
} satisfies Component;
|
||||
kind: occurrence.entry_kind,
|
||||
component: {
|
||||
path: occurrence.logical_path,
|
||||
artifact_id: node.artifact_id,
|
||||
sha256: node.sha256,
|
||||
format: node.format,
|
||||
},
|
||||
};
|
||||
});
|
||||
const roots = [
|
||||
...new Set(
|
||||
inventory.occurrences.flatMap(({ logical_path: path }) => {
|
||||
const match = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path);
|
||||
return match?.[1] === undefined ? [] : [match[1]];
|
||||
}),
|
||||
),
|
||||
].sort(compare);
|
||||
const classified = classifyComponents(all, roots);
|
||||
const all = entries.flatMap(({ component }) =>
|
||||
component === undefined ? [] : [component],
|
||||
);
|
||||
const subjectName = evidence[0]?.subject?.name ?? "";
|
||||
const roots = applicationRoots(entries, subjectName);
|
||||
const bundles = detectBundles(entries, roots);
|
||||
const classified = classifyComponents(entries, all, roots);
|
||||
const runtimeFamilies = identifyRuntimeFamilies(all);
|
||||
const bridgeProjection = identifyBridgeCandidates(
|
||||
classified.javascript,
|
||||
|
|
@ -124,19 +143,35 @@ export const projectAppleApplication = (
|
|||
...classified.native_libraries,
|
||||
]),
|
||||
);
|
||||
const limitations = projectionLimitations(inventory.complete, roots);
|
||||
const uninventoriedImage =
|
||||
rootFormat === "dmg" && inventory.occurrences.length === 1;
|
||||
const complete = inventory.complete && !uninventoriedImage;
|
||||
const limitations = projectionLimitations({
|
||||
complete: inventory.complete,
|
||||
uninventoriedImage,
|
||||
roots,
|
||||
bundles,
|
||||
sidecars: entries.some(({ path }) => isSidecar(path)),
|
||||
symlinks: entries.some(({ kind }) => kind === "symlink"),
|
||||
});
|
||||
const withoutId = {
|
||||
root_sha256: inventory.manifest.root_sha256,
|
||||
root_format: "ipa" as const,
|
||||
root_format: rootFormat,
|
||||
platforms: platformsOf(bundles),
|
||||
source_evidence_ids: evidence
|
||||
.map(({ evidence_id: id }) => id)
|
||||
.sort(compare),
|
||||
application_roots: roots,
|
||||
bundles,
|
||||
components: classified,
|
||||
symlinks: entries
|
||||
.filter(({ kind }) => kind === "symlink")
|
||||
.map(({ path }) => path)
|
||||
.sort(compare),
|
||||
runtime_families: runtimeFamilies,
|
||||
bridge_candidates: bridgeProjection.candidates,
|
||||
coverage: {
|
||||
status: inventory.complete
|
||||
status: complete
|
||||
? ("complete-within-inventory" as const)
|
||||
: ("partial" as const),
|
||||
inventory_complete: inventory.complete,
|
||||
|
|
@ -149,28 +184,59 @@ export const projectAppleApplication = (
|
|||
});
|
||||
};
|
||||
|
||||
const appleRootFormat = (format: string): RootFormat => {
|
||||
const root = APPLE_ROOT_FORMATS.find((candidate) => candidate === format);
|
||||
if (root === undefined)
|
||||
throw new TypeError(
|
||||
`Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`,
|
||||
);
|
||||
return root;
|
||||
};
|
||||
|
||||
const classifyComponents = (
|
||||
entries: readonly AppleInventoryEntry[],
|
||||
all: readonly Component[],
|
||||
roots: readonly string[],
|
||||
) => {
|
||||
const withinApp = (path: string): boolean =>
|
||||
roots.some((root) => path.startsWith(`${root}/`));
|
||||
roots.some((root) => isWithin(path, root));
|
||||
const anatomy = (pattern: RegExp): Component[] =>
|
||||
entries.flatMap(({ path, kind, component }) =>
|
||||
kind === "file" &&
|
||||
component !== undefined &&
|
||||
!isSidecar(path) &&
|
||||
withinApp(path) &&
|
||||
pattern.test(path)
|
||||
? [component]
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
bundle_metadata: all.filter(({ path }) =>
|
||||
/(?:^|\/)Info\.plist$/u.test(path),
|
||||
),
|
||||
executables: all.filter(
|
||||
({ path, format }) =>
|
||||
withinApp(path) && ["mach-o", "mach-o-universal"].includes(format),
|
||||
({ path, format }) => withinApp(path) && isMachOFormat(format),
|
||||
),
|
||||
frameworks: all.filter(({ path }) => /\.framework\//u.test(path)),
|
||||
native_libraries: all.filter(({ path }) => /\.(?:dylib|so)$/iu.test(path)),
|
||||
javascript: all.filter(({ format }) => format === "javascript-bundle"),
|
||||
signing: all.filter(({ path }) =>
|
||||
/(?:^|\/)(?:embedded\.mobileprovision|_CodeSignature\/CodeResources)$/u.test(
|
||||
/(?:^|\/)(?:embedded\.(?:mobileprovision|provisionprofile)|_CodeSignature\/CodeResources)$/u.test(
|
||||
path,
|
||||
),
|
||||
),
|
||||
privileged_helpers: anatomy(
|
||||
/(?:^|\/)Contents\/Library\/LaunchServices\/[^/]+$/u,
|
||||
),
|
||||
launchd_plists: anatomy(
|
||||
/(?:^|\/)Contents\/Library\/Launch(?:Agents|Daemons)\/[^/]+\.plist$/u,
|
||||
).map((component) => ({
|
||||
...component,
|
||||
domain: component.path.includes("/LaunchDaemons/")
|
||||
? ("daemon" as const)
|
||||
: ("agent" as const),
|
||||
})),
|
||||
helpers: anatomy(/(?:^|\/)Contents\/Helpers\/[^/]+$/u),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -241,18 +307,46 @@ const bridgeBasis = (
|
|||
return "javascript-and-native-content";
|
||||
};
|
||||
|
||||
const projectionLimitations = (
|
||||
complete: boolean,
|
||||
roots: readonly string[],
|
||||
): string[] => [
|
||||
...(!complete
|
||||
const projectionLimitations = (facts: {
|
||||
readonly complete: boolean;
|
||||
readonly uninventoriedImage: boolean;
|
||||
readonly roots: readonly string[];
|
||||
readonly bundles: readonly z.infer<typeof appleBundleSchema>[];
|
||||
readonly sidecars: boolean;
|
||||
readonly symlinks: boolean;
|
||||
}): string[] => [
|
||||
...(!facts.complete
|
||||
? ["Source inventory pages are incomplete; absence is unknown."]
|
||||
: []),
|
||||
...(roots.length === 0
|
||||
...(facts.uninventoriedImage
|
||||
? [
|
||||
"No Payload/*.app directory was present in the supplied inventory pages.",
|
||||
"The DMG's contents were not inventoried on this host; bundle absence is unknown.",
|
||||
]
|
||||
: []),
|
||||
...(facts.roots.length === 0
|
||||
? [
|
||||
"No application bundle (Payload/*.app or *.app/Contents) was present in the supplied inventory pages.",
|
||||
]
|
||||
: []),
|
||||
...(facts.bundles.some(
|
||||
({ layout, info_plist_path: plist }) =>
|
||||
layout === "versioned-framework" && plist === null,
|
||||
)
|
||||
? [
|
||||
"A versioned framework has zero or several Versions/*/Resources/Info.plist files; the one Versions/Current selects is unknown because symlink targets are not inventoried.",
|
||||
]
|
||||
: []),
|
||||
...(facts.symlinks
|
||||
? [
|
||||
"Symlinks are reported by path only; their targets are not inventoried, so bundles are reported at their real paths.",
|
||||
]
|
||||
: []),
|
||||
...(facts.sidecars
|
||||
? [
|
||||
"AppleDouble sidecar entries (._* files and __MACOSX/) describe neighbouring files and are excluded from bundle roles.",
|
||||
]
|
||||
: []),
|
||||
"Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.",
|
||||
"Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.",
|
||||
"Bridge candidates are path-based hypotheses, not observed runtime calls.",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { digestSchema, prefixedDigestSchema } from "./digests.js";
|
||||
|
||||
const pathSchema = z.string().min(1);
|
||||
|
||||
const BUNDLE_EXTENSION =
|
||||
/\.(?:app|appex|xpc|framework|systemextension|dext|bundle|plugin|qlgenerator|mdimporter)$/iu;
|
||||
const MACH_O_FORMATS: readonly string[] = ["mach-o", "mach-o-universal"];
|
||||
|
||||
/** One inventoried file or directory with its content identity. */
|
||||
export const appleComponentSchema = z.strictObject({
|
||||
path: pathSchema,
|
||||
artifact_id: prefixedDigestSchema("art"),
|
||||
sha256: digestSchema,
|
||||
format: z.string().min(1),
|
||||
});
|
||||
|
||||
/** One application root or nested bundle classified by path convention. */
|
||||
export const appleBundleSchema = z.strictObject({
|
||||
path: pathSchema,
|
||||
parent_path: pathSchema.nullable(),
|
||||
layout: z.enum(["shallow", "macos-deep", "versioned-framework"]),
|
||||
role: z.enum([
|
||||
"application",
|
||||
"app-extension",
|
||||
"xpc-service",
|
||||
"framework",
|
||||
"login-item",
|
||||
"system-extension",
|
||||
"driver-extension",
|
||||
"plug-in",
|
||||
"resource-bundle",
|
||||
"bundle",
|
||||
"helper-application",
|
||||
"nested-application",
|
||||
]),
|
||||
role_basis: z.literal("path-convention"),
|
||||
info_plist_path: pathSchema.nullable(),
|
||||
executable_candidates: z.array(pathSchema),
|
||||
signing_paths: z.array(pathSchema),
|
||||
});
|
||||
|
||||
type Component = z.infer<typeof appleComponentSchema>;
|
||||
type Bundle = z.infer<typeof appleBundleSchema>;
|
||||
|
||||
/** One inventory occurrence; symlinks and directories may lack content identity. */
|
||||
export interface AppleInventoryEntry {
|
||||
readonly path: string;
|
||||
readonly kind: "file" | "directory" | "symlink" | "slice";
|
||||
readonly component: Component | undefined;
|
||||
}
|
||||
type Entry = AppleInventoryEntry;
|
||||
|
||||
/** Report whether an inventory format is a thin or universal Mach-O. */
|
||||
export const isMachOFormat = (format: string): boolean =>
|
||||
MACH_O_FORMATS.includes(format);
|
||||
|
||||
/** AppleDouble metadata written beside archived files (`._name`, `__MACOSX/`). */
|
||||
export const isSidecar = (path: string): boolean => {
|
||||
const segments = path.split("/");
|
||||
return (
|
||||
segments.includes("__MACOSX") || (segments.at(-1) ?? "").startsWith("._")
|
||||
);
|
||||
};
|
||||
|
||||
export const isWithin = (path: string, root: string): boolean =>
|
||||
root === "." || path === root || path.startsWith(`${root}/`);
|
||||
|
||||
/**
|
||||
* Outermost iOS (`Payload/X.app`) and macOS (`X.app/Contents/…`) applications.
|
||||
* An inventoried `.app` directory is its own root, `.`.
|
||||
*/
|
||||
export const applicationRoots = (
|
||||
entries: readonly Entry[],
|
||||
subjectName: string,
|
||||
): string[] => {
|
||||
const found = new Set<string>();
|
||||
for (const { path } of entries) {
|
||||
if (isSidecar(path)) continue;
|
||||
const ios = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path);
|
||||
if (ios?.[1] !== undefined) {
|
||||
found.add(ios[1]);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/\.app$/iu.test(subjectName) &&
|
||||
/^Contents\/(?:Info\.plist|MacOS\/[^/]+)$/u.test(path)
|
||||
) {
|
||||
found.add(".");
|
||||
continue;
|
||||
}
|
||||
const segments = path.split("/");
|
||||
const index = segments.findIndex(
|
||||
(segment, position) =>
|
||||
/\.app$/iu.test(segment) && segments[position + 1] === "Contents",
|
||||
);
|
||||
if (index >= 0) found.add(segments.slice(0, index + 1).join("/"));
|
||||
}
|
||||
const roots = [...found];
|
||||
return roots
|
||||
.filter(
|
||||
(root) => !roots.some((other) => other !== root && isWithin(root, other)),
|
||||
)
|
||||
.sort(compare);
|
||||
};
|
||||
|
||||
export const detectBundles = (
|
||||
entries: readonly Entry[],
|
||||
roots: readonly string[],
|
||||
): Bundle[] => {
|
||||
const paths = new Set<string>(roots);
|
||||
for (const { path, kind } of entries) {
|
||||
if (isSidecar(path)) continue;
|
||||
const root = roots.find((candidate) => isWithin(path, candidate));
|
||||
if (root === undefined) continue;
|
||||
const segments = path.split("/");
|
||||
const start = root === "." ? 0 : root.split("/").length;
|
||||
for (let index = start; index < segments.length; index++) {
|
||||
const segment = segments[index] ?? "";
|
||||
const leaf = index === segments.length - 1;
|
||||
// A symlink or file named like a bundle has no observed contents.
|
||||
if (BUNDLE_EXTENSION.test(segment) && (!leaf || kind === "directory"))
|
||||
paths.add(segments.slice(0, index + 1).join("/"));
|
||||
}
|
||||
}
|
||||
const tree = indexTree(entries);
|
||||
return [...paths].sort(compare).map((path) => {
|
||||
const layout = bundleLayout(path, tree);
|
||||
return {
|
||||
path,
|
||||
parent_path: enclosingBundle(path, paths),
|
||||
layout,
|
||||
role: roots.includes(path) ? "application" : bundleRole(path),
|
||||
role_basis: "path-convention",
|
||||
info_plist_path: infoPlistPath(path, layout, tree),
|
||||
executable_candidates: executableCandidates(path, layout, tree),
|
||||
signing_paths: signingPaths(path, layout, tree),
|
||||
} satisfies Bundle;
|
||||
});
|
||||
};
|
||||
|
||||
/** Directory children (including implied ancestors) and files, indexed once. */
|
||||
interface Tree {
|
||||
readonly children: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
readonly files: ReadonlyMap<string, Component>;
|
||||
}
|
||||
|
||||
const indexTree = (entries: readonly Entry[]): Tree => {
|
||||
const children = new Map<string, Set<string>>();
|
||||
const files = new Map<string, Component>();
|
||||
for (const { path, kind, component } of entries) {
|
||||
if (isSidecar(path)) continue;
|
||||
if (kind === "file" && component !== undefined) files.set(path, component);
|
||||
const segments = path.split("/");
|
||||
let parent = "";
|
||||
for (const segment of segments) {
|
||||
const names = children.get(parent) ?? new Set<string>();
|
||||
names.add(segment);
|
||||
children.set(parent, names);
|
||||
parent = joinPath(parent, segment);
|
||||
}
|
||||
}
|
||||
return { children, files };
|
||||
};
|
||||
|
||||
const joinPath = (directory: string, name: string): string =>
|
||||
directory === "" ? name : `${directory}/${name}`;
|
||||
|
||||
const directoryOf = (bundle: string): string => (bundle === "." ? "" : bundle);
|
||||
|
||||
const enclosingBundle = (
|
||||
path: string,
|
||||
bundles: ReadonlySet<string>,
|
||||
): string | null => {
|
||||
if (path === ".") return null;
|
||||
const segments = path.split("/");
|
||||
for (let length = segments.length - 1; length > 0; length--) {
|
||||
const candidate = segments.slice(0, length).join("/");
|
||||
if (bundles.has(candidate)) return candidate;
|
||||
}
|
||||
return bundles.has(".") ? "." : null;
|
||||
};
|
||||
|
||||
const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => {
|
||||
const names = tree.children.get(directoryOf(bundle));
|
||||
if (names?.has("Contents") === true) return "macos-deep";
|
||||
if (names?.has("Versions") === true) return "versioned-framework";
|
||||
return "shallow";
|
||||
};
|
||||
|
||||
const bundleRole = (bundle: string): Bundle["role"] => {
|
||||
const segments = bundle.split("/");
|
||||
const name = (segments.at(-1) ?? "").toLowerCase();
|
||||
const container = segments.at(-2);
|
||||
if (name.endsWith(".appex")) return "app-extension";
|
||||
if (name.endsWith(".xpc")) return "xpc-service";
|
||||
if (name.endsWith(".framework")) return "framework";
|
||||
if (name.endsWith(".systemextension")) return "system-extension";
|
||||
if (name.endsWith(".dext")) return "driver-extension";
|
||||
if (name.endsWith(".bundle")) {
|
||||
if (container === "PlugIns") return "plug-in";
|
||||
return container === "Resources" ? "resource-bundle" : "bundle";
|
||||
}
|
||||
if (!name.endsWith(".app")) return "plug-in";
|
||||
if (container === "LoginItems" && segments.at(-3) === "Library")
|
||||
return "login-item";
|
||||
if (container === "Helpers") return "helper-application";
|
||||
return "nested-application";
|
||||
};
|
||||
|
||||
/** Files directly inside one directory; nested bundles are deeper paths. */
|
||||
const directFiles = (directory: string, tree: Tree): string[] =>
|
||||
[...(tree.children.get(directory) ?? [])]
|
||||
.map((name) => joinPath(directory, name))
|
||||
.filter((path) => tree.files.has(path))
|
||||
.sort(compare);
|
||||
|
||||
/** Content directories; `Versions/Current` is a symlink outside the inventory. */
|
||||
const contentDirectories = (
|
||||
bundle: string,
|
||||
layout: Bundle["layout"],
|
||||
tree: Tree,
|
||||
): string[] => {
|
||||
const directory = directoryOf(bundle);
|
||||
if (layout === "macos-deep") return [joinPath(directory, "Contents")];
|
||||
if (layout === "shallow") return [directory];
|
||||
const versions = joinPath(directory, "Versions");
|
||||
return [...(tree.children.get(versions) ?? [])]
|
||||
.filter((version) => version !== "Current")
|
||||
.map((version) => joinPath(versions, version))
|
||||
.sort(compare);
|
||||
};
|
||||
|
||||
const infoPlistPath = (
|
||||
bundle: string,
|
||||
layout: Bundle["layout"],
|
||||
tree: Tree,
|
||||
): string | null => {
|
||||
const candidates = contentDirectories(bundle, layout, tree)
|
||||
.map((directory) =>
|
||||
joinPath(
|
||||
directory,
|
||||
layout === "versioned-framework"
|
||||
? "Resources/Info.plist"
|
||||
: "Info.plist",
|
||||
),
|
||||
)
|
||||
.filter((path) => tree.files.has(path));
|
||||
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
||||
};
|
||||
|
||||
const executableCandidates = (
|
||||
bundle: string,
|
||||
layout: Bundle["layout"],
|
||||
tree: Tree,
|
||||
): string[] =>
|
||||
contentDirectories(bundle, layout, tree)
|
||||
.flatMap((directory) =>
|
||||
directFiles(
|
||||
layout === "macos-deep" ? joinPath(directory, "MacOS") : directory,
|
||||
tree,
|
||||
),
|
||||
)
|
||||
.filter((path) =>
|
||||
MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""),
|
||||
);
|
||||
|
||||
const signingPaths = (
|
||||
bundle: string,
|
||||
layout: Bundle["layout"],
|
||||
tree: Tree,
|
||||
): string[] =>
|
||||
contentDirectories(bundle, layout, tree)
|
||||
.flatMap((directory) => [
|
||||
joinPath(directory, "_CodeSignature/CodeResources"),
|
||||
joinPath(directory, "embedded.provisionprofile"),
|
||||
joinPath(directory, "embedded.mobileprovision"),
|
||||
])
|
||||
.filter((path) => tree.files.has(path))
|
||||
.sort(compare);
|
||||
|
||||
export const platformsOf = (bundles: readonly Bundle[]): ("ios" | "macos")[] =>
|
||||
[
|
||||
...new Set(
|
||||
bundles.flatMap(({ role, layout }) => {
|
||||
if (role !== "application") return [];
|
||||
if (layout === "macos-deep") return ["macos" as const];
|
||||
if (layout === "shallow") return ["ios" as const];
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
].sort(compare);
|
||||
|
||||
const compare = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
|||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "26",
|
||||
skillVersion: "27",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
TextReader,
|
||||
|
|
@ -149,9 +149,33 @@ describe("Apple application projection", () => {
|
|||
expect(JSON.stringify(left)).not.toContain("opaque signing bytes");
|
||||
});
|
||||
|
||||
it("rejects non-IPA Evidence", async () => {
|
||||
it("reports a ZIP without an application bundle instead of guessing one", async () => {
|
||||
const inventory =
|
||||
await createNonApplicationZipInventory("rea-apple-invalid-");
|
||||
await createNonApplicationZipInventory("rea-apple-empty-");
|
||||
const projection = appleApplicationProjectionResultSchema.parse(
|
||||
requireSuccessfulProjection(
|
||||
projectAppleApplicationEvidence({ inventory_evidence: [inventory] }),
|
||||
).normalized_result,
|
||||
);
|
||||
expect(projection).toMatchObject({
|
||||
root_format: "zip",
|
||||
application_roots: [],
|
||||
bundles: [],
|
||||
});
|
||||
expect(projection.limitations).toContain(
|
||||
"No application bundle (Payload/*.app or *.app/Contents) was present in the supplied inventory pages.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects inventories of non-Apple package formats", async () => {
|
||||
const root = await createTestTempDirectory("rea-apple-invalid-");
|
||||
const path = join(root, "fixture.apk");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("AndroidManifest.xml", new TextReader("<manifest/>"));
|
||||
await writeFile(path, await writer.close());
|
||||
const inventory = parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
expect(
|
||||
projectAppleApplicationEvidence({ inventory_evidence: [inventory] }),
|
||||
).toMatchObject({
|
||||
|
|
@ -161,6 +185,74 @@ describe("Apple application projection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("macOS application projection", () => {
|
||||
it("projects a macOS .app directory inventory with nested bundle roles", async () => {
|
||||
const root = await createTestTempDirectory("rea-apple-macos-");
|
||||
const app = join(root, "Fixture.app");
|
||||
const machO = Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]);
|
||||
const files: [string, string | Uint8Array][] = [
|
||||
[
|
||||
"Contents/Info.plist",
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
],
|
||||
["Contents/MacOS/Fixture", machO],
|
||||
["Contents/XPCServices/Fetch.xpc/Contents/MacOS/Fetch", machO],
|
||||
["Contents/PlugIns/Share.appex/Contents/MacOS/Share", machO],
|
||||
["Contents/Library/LoginItems/Login.app/Contents/MacOS/Login", machO],
|
||||
["Contents/Library/LaunchServices/com.example.helper", machO],
|
||||
[
|
||||
"Contents/Library/LaunchDaemons/com.example.helper.plist",
|
||||
"<plist><dict/></plist>",
|
||||
],
|
||||
["Contents/Frameworks/Core.framework/Versions/A/Core", machO],
|
||||
];
|
||||
for (const [relative, content] of files) {
|
||||
await mkdir(dirname(join(app, relative)), { recursive: true });
|
||||
await writeFile(join(app, relative), content);
|
||||
}
|
||||
await symlink(
|
||||
"A",
|
||||
join(app, "Contents/Frameworks/Core.framework/Versions/Current"),
|
||||
);
|
||||
|
||||
const inventory = parseEvidence(
|
||||
await runProviderAnalysis(app, "inventory_artifact", {}),
|
||||
);
|
||||
const result = projectAppleApplicationEvidence({
|
||||
inventory_evidence: [inventory],
|
||||
});
|
||||
const projection = appleApplicationProjectionResultSchema.parse(
|
||||
requireSuccessfulProjection(result).normalized_result,
|
||||
);
|
||||
expect(requireSuccessfulProjection(result).subject?.format).toBe(
|
||||
"directory",
|
||||
);
|
||||
expect(projection).toMatchObject({
|
||||
root_format: "directory",
|
||||
platforms: ["macos"],
|
||||
application_roots: ["."],
|
||||
symlinks: ["Contents/Frameworks/Core.framework/Versions/Current"],
|
||||
coverage: { status: "complete-within-inventory" },
|
||||
});
|
||||
expect(projection.bundles.map(({ path, role }) => [path, role])).toEqual([
|
||||
[".", "application"],
|
||||
["Contents/Frameworks/Core.framework", "framework"],
|
||||
["Contents/Library/LoginItems/Login.app", "login-item"],
|
||||
["Contents/PlugIns/Share.appex", "app-extension"],
|
||||
["Contents/XPCServices/Fetch.xpc", "xpc-service"],
|
||||
]);
|
||||
expect(
|
||||
projection.components.privileged_helpers.map(({ path }) => path),
|
||||
).toEqual(["Contents/Library/LaunchServices/com.example.helper"]);
|
||||
expect(projection.components.launchd_plists).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "Contents/Library/LaunchDaemons/com.example.helper.plist",
|
||||
domain: "daemon",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple application projection completeness", () => {
|
||||
it("returns every component and bridge hypothesis from fragments of a real ZIP inventory", async () => {
|
||||
const projection = await createCompleteAppleProjection();
|
||||
|
|
|
|||
Loading…
Reference in New Issue