From ce8e969112e452b754808d450c9ed7a596aee526 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:43:47 +0000 Subject: [PATCH] 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 --- docs/native-investigation.md | 8 +- docs/testing.md | 2 +- scripts/verify-code-signature.mjs | 6 + src/domain/codeSigningPosture.test.ts | 59 +++++++-- src/domain/codeSigningPosture.ts | 113 +++++++++++++----- src/native/NativeMacOSProvider.ts | 23 ++++ src/native/NativeSignaturePosture.ts | 22 ++-- .../native/nativeSignaturePosture.test.ts | 97 +++++++++++++++ tests/fixtures/nativeCommands.ts | 11 +- 9 files changed, 295 insertions(+), 46 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f2fc219fbf..45b8b2247a 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 ` reads compiled `Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw rendition fields, pagination and exact UI resource-name matches are returned. diff --git a/docs/testing.md b/docs/testing.md index f43373781c..3cc11a628c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/scripts/verify-code-signature.mjs b/scripts/verify-code-signature.mjs index 9980668096..e0dafd6f28 100644 --- a/scripts/verify-code-signature.mjs +++ b/scripts/verify-code-signature.mjs @@ -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"); diff --git a/src/domain/codeSigningPosture.test.ts b/src/domain/codeSigningPosture.test.ts index 0806500baf..e64bc3aa91 100644 --- a/src/domain/codeSigningPosture.test.ts +++ b/src/domain/codeSigningPosture.test.ts @@ -24,6 +24,13 @@ const directory = ( executable_segment_flags: 1, }); +type PostureInput = Parameters[0]; + +const derive = ( + input: Omit & + Partial, +) => deriveSecurityFacets({ mixedSlices: false, appleOrigin: false, ...input }); + const states = (facets: ReturnType) => 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, diff --git a/src/domain/codeSigningPosture.ts b/src/domain/codeSigningPosture.ts index edcf0a62b1..5698258150 100644 --- a/src/domain/codeSigningPosture.ts +++ b/src/domain/codeSigningPosture.ts @@ -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; +}; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 7e21baca63..9f13d465a0 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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, diff --git a/src/native/NativeSignaturePosture.ts b/src/native/NativeSignaturePosture.ts index 9704619e7b..2c0a1fe846 100644 --- a/src/native/NativeSignaturePosture.ts +++ b/src/native/NativeSignaturePosture.ts @@ -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 => { - const bundle = target.sourcePath; - if (bundle?.toLowerCase().endsWith(".app") !== true) + const bundle = appBundle(target); + if (bundle === undefined) return { status: "not-applicable", path: null, diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index 8338e31d6b..106d6f6d28 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -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; + } +} diff --git a/tests/fixtures/nativeCommands.ts b/tests/fixtures/nativeCommands.ts index b4dcf2dded..29304171aa 100644 --- a/tests/fixtures/nativeCommands.ts +++ b/tests/fixtures/nativeCommands.ts @@ -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",