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:
1 parent
73eb20a4b9
commit
ce8e969112
9 files changed
+295
-46
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+9
-2
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user