fix(native): require Apple origin for platform facets and keep cancellation

- Treat a CodeDirectory platform identifier as a platform binary only when
  the signature satisfies `anchor apple`, checked with
  `codesign --verify -R="anchor apple"`; otherwise the dependent facets are
  unknown. Developer ID code chains to Apple Root CA but fails this check.
- Report every security facet as unknown when architecture slices differ in
  signing state.
- Map an aborted signal that escapes a native helper, such as ticket
  hashing, to AnalysisCancelledError instead of an output error.
- Treat a target as an app bundle only when it was opened from a bundle
  directory, so a regular file named X.app is verified as a bare Mach-O.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 12:43:47 +00:00
1 parent 73eb20a4b9
commit ce8e969112
9 files changed
+295 -46

No files matched your search

+7 -1
View File
@@ -33,7 +33,13 @@ explicit limitation; architecture inventory locations remain available.
- executable-memory policy;
- App Sandbox.
System Integrity Protection, AMFI and setuid policy are not evaluated.
A CodeDirectory platform identifier counts as a platform binary only
when the signature also satisfies `anchor apple` (Apple's own code;
`codesign --verify -R="anchor apple"`). Otherwise the facets that depend
on it are `unknown`. A universal file whose slices differ in signing
state reports every facet as `unknown`, because no one CodeDirectory
describes the process. System Integrity Protection, AMFI and setuid
policy are not evaluated.
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
+1 -1
View File
@@ -86,7 +86,7 @@ copies of a clang-built fixture with real `codesign`: ad hoc, hardened
runtime, hardened runtime with relaxing entitlements, and `library,restrict`
flags. It checks that `inspect-signature` decodes those flags and derives the
expected security facets. It also checks a platform binary, which must report a
platform identifier, and bundles with an added unsealed file, at the top level
platform identifier and pass the `anchor apple` origin check, and bundles with an added unsealed file, at the top level
and inside a nested XPC service, which must fail deep strict verification. CLI and stdio MCP results must match.
`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
+6
View File
@@ -131,6 +131,12 @@ try {
assert.notEqual(platform.code_directory.platform_identifier, null);
assert.equal(platform.verification.status, "valid");
assert.equal(facets(platform)["library-validation"], "enforced");
// The platform rules rest on a real `anchor apple` requirement check.
assert.ok(
platform.provenance.some(({ command }) =>
command.includes("-R=anchor apple"),
),
);
// Only deep verification sees a file added inside nested code.
const nested = join(root, "Nested.app");
+52 -7
View File
@@ -24,6 +24,13 @@ const directory = (
executable_segment_flags: 1,
});
type PostureInput = Parameters<typeof deriveSecurityFacets>[0];
const derive = (
input: Omit<PostureInput, "mixedSlices" | "appleOrigin"> &
Partial<PostureInput>,
) => deriveSecurityFacets({ mixedSlices: false, appleOrigin: false, ...input });
const states = (facets: ReturnType<typeof deriveSecurityFacets>) =>
Object.fromEntries(facets.map(({ facet, state }) => [facet, state]));
@@ -47,7 +54,7 @@ describe("derived security facets", () => {
for (const signed of [false, true])
expect(
states(
deriveSecurityFacets({
derive({
signed,
codeDirectory: signed ? directory(0x2) : null,
entitlements: null,
@@ -66,7 +73,7 @@ describe("derived security facets", () => {
it("restricts hardened-runtime code unless entitlements relax it", () => {
expect(
states(
deriveSecurityFacets({
derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: { "com.apple.security.app-sandbox": true },
@@ -80,7 +87,7 @@ describe("derived security facets", () => {
"executable-memory": "restricted",
"app-sandbox": "sandboxed",
});
const relaxed = deriveSecurityFacets({
const relaxed = derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: {
@@ -104,7 +111,7 @@ describe("derived security facets", () => {
});
it("lets explicit flags and platform identity override entitlements", () => {
const flagged = deriveSecurityFacets({
const flagged = derive({
signed: true,
codeDirectory: directory(0x2000 | 0x800 | RUNTIME),
entitlements: {
@@ -118,11 +125,12 @@ describe("derived security facets", () => {
});
expect(
states(
deriveSecurityFacets({
derive({
signed: true,
codeDirectory: directory(RUNTIME, 26),
entitlements: null,
entitlementsKnown: true,
appleOrigin: true,
}),
),
).toMatchObject({
@@ -131,11 +139,48 @@ describe("derived security facets", () => {
"debugger-attach": "blocked",
});
});
});
describe("security facets that need more than one CodeDirectory", () => {
it("applies platform policy only to Apple-signed platform identifiers", () => {
const crafted = derive({
signed: true,
codeDirectory: directory(0, 26),
entitlements: null,
entitlementsKnown: true,
});
expect(states(crafted)).toMatchObject({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
});
expect(crafted[0]?.evidence).toEqual([
"platform identifier",
"Apple origin not verified",
]);
});
it("leaves every facet unknown when slices differ in signing state", () => {
const mixed = derive({
signed: true,
codeDirectory: directory(0x2000 | RUNTIME, 26),
entitlements: null,
entitlementsKnown: false,
appleOrigin: true,
mixedSlices: true,
});
expect(new Set(mixed.map(({ state }) => state))).toEqual(
new Set(["unknown"]),
);
expect(mixed[0]?.evidence).toEqual([
"architecture slices differ in signing state",
]);
});
it("keeps facets unknown when flags or entitlements are unavailable", () => {
expect(
states(
deriveSecurityFacets({
derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: null,
@@ -151,7 +196,7 @@ describe("derived security facets", () => {
});
expect(
states(
deriveSecurityFacets({
derive({
signed: true,
codeDirectory: directory(null),
entitlements: null,
+85 -28
View File
@@ -146,8 +146,30 @@ interface PostureInput {
readonly entitlements: JsonValue | null;
/** False when mixed or unreadable signing states hide the aggregate entitlements. */
readonly entitlementsKnown: boolean;
/** Architecture slices differ in signing state, so no one CodeDirectory describes the process. */
readonly mixedSlices: boolean;
/**
* The signature satisfies `anchor apple`. Only then does a CodeDirectory
* platform identifier mean an Apple platform binary.
*/
readonly appleOrigin: boolean;
}
/** Whether the CodeDirectory claims a platform binary, and whether Apple signed it. */
const platformBinary = (
input: PostureInput,
): "apple" | "unverified" | "none" =>
(input.codeDirectory?.platform_identifier ?? null) === null
? "none"
: input.appleOrigin
? "apple"
: "unverified";
const UNVERIFIED_PLATFORM = [
"platform identifier",
"Apple origin not verified",
] as const;
const has = (input: PostureInput, flag: string): boolean =>
input.codeDirectory?.flags?.names.includes(flag) === true;
@@ -169,13 +191,21 @@ const libraryValidation = (input: PostureInput): SecurityFacet => {
evidence: ["CodeDirectory flags unavailable"],
explanation,
};
if (input.codeDirectory.platform_identifier !== null)
return {
...base,
state: "enforced",
evidence: ["platform binary"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "enforced",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (has(input, "library-validation"))
return {
...base,
@@ -232,13 +262,21 @@ const dyldEnvironment = (input: PostureInput): SecurityFacet => {
evidence: ["CodeDirectory flag restrict"],
explanation,
};
if ((input.codeDirectory?.platform_identifier ?? null) !== null)
return {
...base,
state: "ignored",
evidence: ["platform binary"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "ignored",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (!has(input, "runtime"))
return input.signed && (input.codeDirectory?.flags ?? null) === null
? {
@@ -296,13 +334,21 @@ const debuggerAttach = (input: PostureInput): SecurityFacet => {
evidence: ["com.apple.security.get-task-allow"],
explanation,
};
if ((input.codeDirectory?.platform_identifier ?? null) !== null)
return {
...base,
state: "blocked",
evidence: ["platform binary"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "blocked",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (has(input, "runtime"))
return input.entitlementsKnown
? {
@@ -411,10 +457,21 @@ const appSandbox = (input: PostureInput): SecurityFacet => {
};
/** Derive launch-time security facets from observed flags and entitlements. */
export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => [
libraryValidation(input),
dyldEnvironment(input),
debuggerAttach(input),
executableMemory(input),
appSandbox(input),
];
export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => {
const facets = [
libraryValidation(input),
dyldEnvironment(input),
debuggerAttach(input),
executableMemory(input),
appSandbox(input),
];
// Each slice runs under its own signature; the inspected one does not
// describe an unsigned slice of the same file.
return input.mixedSlices
? facets.map((facet) => ({
...facet,
state: "unknown" as const,
evidence: ["architecture slices differ in signing state"],
}))
: facets;
};
+23
View File
@@ -195,6 +195,10 @@ class NativeMacOSClient implements AnalysisClient {
)
: observation;
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
// A helper that observed the abort escapes as a DOMException.
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisOutputError(operation, "Native output parsing failed", {
cause,
@@ -351,6 +355,23 @@ class NativeMacOSClient implements AnalysisClient {
entitlements.value,
verify.value,
];
// A platform identifier means a platform binary only in Apple's own
// signatures, which satisfy `anchor apple`; Developer ID code does not.
let appleOrigin = false;
if (
!unsigned &&
(parsed.code_directory?.platform_identifier ?? null) !== null
) {
const anchor = await this.#run(
"inspect_signature",
"codesign",
["--verify", "-R=anchor apple", verifyPath],
{ signal, acceptNonZero: true },
);
if (!anchor.ok) return anchor;
captures.push(anchor.value);
appleOrigin = anchor.value.exitCode === 0;
}
const limitations = [
...parsed.limitations,
...SIGNATURE_POSTURE_LIMITATIONS,
@@ -391,6 +412,8 @@ class NativeMacOSClient implements AnalysisClient {
codeDirectory: parsed.code_directory,
entitlements: entitlementValue.value,
entitlementsKnown: unsigned || entitlements.value.exitCode === 0,
mixedSlices: mixedSigning,
appleOrigin,
}),
provenance,
limitations,
+15 -7
View File
@@ -13,10 +13,18 @@ const HASH_CHUNK_BYTES = 64 * 1024;
export const signedCodePath = (target: {
readonly path: string;
readonly sourcePath?: string;
}): string =>
target.sourcePath?.toLowerCase().endsWith(".app") === true
? target.sourcePath
: target.path;
readonly bundleInfoPlist?: string;
}): string => appBundle(target) ?? target.path;
/**
* The app bundle directory a target was opened from. Only a bundle opened as a
* directory carries its Info.plist; a regular file named `X.app` is not one.
*/
const appBundle = (target: {
readonly sourcePath?: string;
readonly bundleInfoPlist?: string;
}): string | undefined =>
target.bundleInfoPlist === undefined ? undefined : target.sourcePath;
/**
* Project a local `codesign --verify --strict` capture without reinterpreting
@@ -59,11 +67,11 @@ export const signatureVerification = (
* Apple's notarization record are not checked, because that needs the network.
*/
export const stapledTicket = async (
target: { readonly sourcePath?: string },
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
signal?: AbortSignal,
): Promise<InspectSignature["stapled_ticket"]> => {
const bundle = target.sourcePath;
if (bundle?.toLowerCase().endsWith(".app") !== true)
const bundle = appBundle(target);
if (bundle === undefined)
return {
status: "not-applicable",
path: null,
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { ok } from "../../../../src/domain/result.js";
import { inspectSignatureSchema } from "../../../../src/domain/nativeInspection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@@ -177,6 +178,68 @@ describe("native signature posture", () => {
});
});
describe("native signature posture boundaries", () => {
it("treats a regular file named .app as a bare Mach-O", async () => {
const directory = await createTestTempDirectory("rea-signature-file-");
const executable = join(directory, "Tool.app");
await writeFile(executable, "fixture");
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient({ ...machoTarget(executable), sourcePath: executable })
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
verification: { path: executable },
stapled_ticket: { status: "not-applicable" },
});
});
it("applies platform policy only after the signature satisfies anchor apple", async () => {
const { app, executable } = await fixtureApp(undefined);
const facets = async (anchorExit: number) => {
const signature = await new NativeMacOSProvider(
new PlatformRunner(anchorExit),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.provenance.map(({ command }) => command.at(-2))).toContain(
"-R=anchor apple",
);
return Object.fromEntries(
result.security_facets.map(({ facet, state }) => [facet, state]),
);
};
expect(await facets(0)).toMatchObject({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
});
expect(await facets(3)).toMatchObject({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
});
});
it("reports cancellation while hashing the stapled ticket", async () => {
const { app, executable } = await fixtureApp("ticket");
const controller = new AbortController();
const signature = await new NativeMacOSProvider(
new AbortAfterVerifyRunner(controller),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {}, { signal: controller.signal });
expect(signature.ok).toBe(false);
if (signature.ok) return;
expect(signature.error).toBeInstanceOf(AnalysisCancelledError);
});
});
/** A signed bundle whose nested helper is unsigned. */
class UnsignedNestedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
@@ -194,3 +257,37 @@ class UnsignedNestedRunner extends FixtureRunner {
});
}
}
/** A platform binary whose `anchor apple` requirement check exits as given. */
class PlatformRunner extends FixtureRunner {
constructor(private readonly anchorExit: number) {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
if (arguments_.includes("-R=anchor apple"))
return ok({ ...result.value, exitCode: this.anchorExit });
if (!arguments_.includes("--verbose=4")) return result;
const stderr = `${result.value.stderr}Platform identifier=26\n`;
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** Cancel the request once local verification has finished. */
class AbortAfterVerifyRunner extends FixtureRunner {
constructor(private readonly controller: AbortController) {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (arguments_[0] === "--verify") this.controller.abort();
return result;
}
}
+9 -2
View File
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
import { ok } from "../../src/domain/result.js";
import type {
@@ -95,10 +96,16 @@ const capture = (
/** Build the Mach-O target identity used by native boundary fixtures. */
export const nativeMachoTarget = (
path: string,
sourcePath?: string,
/** The app bundle directory the target was opened from. */
bundle?: string,
): BinaryTarget => ({
path,
...(sourcePath === undefined ? {} : { sourcePath }),
...(bundle === undefined
? {}
: {
sourcePath: bundle,
bundleInfoPlist: join(bundle, "Contents/Info.plist"),
}),
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o",