diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 51ba729f50..30c855ef05 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -25,6 +25,7 @@ import { type NativeToolName, } from "../contracts/native/nativeToolContracts.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { canonicalJson } from "../domain/comparisonSemantics.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import { AnalysisCancelledError, @@ -412,6 +413,10 @@ class NativeMacOSClient implements AnalysisClient { ); if (!sliceState.ok) return sliceState; const mixedSigning = sliceState.value.mixed; + const aggregateUnsigned = + sliceState.value.signed === undefined + ? unsigned + : !sliceState.value.signed; captures.push(...sliceState.value.captures); limitations.push(...sliceState.value.limitations); // Only the top-level signature decides "unsigned"; unsigned nested @@ -419,7 +424,7 @@ class NativeMacOSClient implements AnalysisClient { const verification = signatureVerification( verify.value, verifyPath, - unsigned, + aggregateUnsigned, ); limitations.push(...(await unconfirmedNestedCode(verification))); if (verification.status === "unknown") @@ -442,12 +447,13 @@ class NativeMacOSClient implements AnalysisClient { ); const result = inspectSignatureSchema.parse({ ...parsed, + signed: !aggregateUnsigned, designated_requirement: requirementText, entitlements: entitlementValue.value, verification, stapled_ticket: await stapledTicket(this.target, signal), security_facets: deriveSecurityFacets({ - signed: !unsigned, + signed: !aggregateUnsigned, codeDirectory: parsed.code_directory, entitlements: entitlementValue.value, entitlementsKnown: unsigned || entitlements.value.exitCode === 0, @@ -485,13 +491,12 @@ class NativeMacOSClient implements AnalysisClient { readonly mixed: boolean; readonly captures: readonly NativeCommandCapture[]; readonly limitations: readonly string[]; + readonly signed?: boolean; }, AnalysisError > > { - const universalArchitectures = universalSliceArchitectures(format); - const universal = - universalArchitectures !== undefined && universalArchitectures.length > 1; + const universal = codeSignArchitectures(format, this.target).length > 1; const gate = !unsigned && (isNonzeroUnsignedObservation(requirements) || @@ -509,6 +514,7 @@ class NativeMacOSClient implements AnalysisClient { mixed: gate || (universal && slices.value.mixed), captures: slices.value.captures, limitations: slices.value.limitations, + signed: slices.value.signed, }); } @@ -536,6 +542,7 @@ class NativeMacOSClient implements AnalysisClient { const signed: string[] = []; const unsigned: string[] = []; const unclassified: string[] = []; + const postures = new Map(); for (const architecture of codeSignArchitectures(format, this.target)) { const slice = await this.#run( "inspect_signature", @@ -547,6 +554,15 @@ class NativeMacOSClient implements AnalysisClient { captures.push(slice.value); if (slice.value.exitCode === 0) { signed.push(architecture); + const posture = await this.#slicePosture( + architecture, + slice.value, + signal, + ); + if (!posture.ok) return posture; + captures.push(posture.value.capture); + if (posture.value.posture === null) unclassified.push(architecture); + else postures.set(architecture, posture.value.posture); continue; } if (isNonzeroUnsignedObservation(slice.value)) @@ -555,11 +571,21 @@ class NativeMacOSClient implements AnalysisClient { } // Slices differ in signing state when a signed slice coexists with an // unsigned or unclassifiable one; all-signed or all-unsigned files agree. + // Signed slices can still disagree among themselves: per-architecture + // display is authoritative per slice, so differing flags or entitlements + // also make the aggregate facets unknown. + const postureDiffers = new Set(postures.values()).size > 1; const mixed = - signed.length > 0 && (unsigned.length > 0 || unclassified.length > 0); + unclassified.length > 0 || + (signed.length > 0 && unsigned.length > 0) || + postureDiffers; const limitations: string[] = []; if (unsigned.length > 0) limitations.push(`Unsigned Mach-O slices: ${unsigned.join(", ")}.`); + if (postureDiffers && unsigned.length === 0 && unclassified.length === 0) + limitations.push( + "Signed Mach-O slices differ in CodeDirectory flags or entitlements; aggregate facets are unknown.", + ); if (unclassified.length > 0) limitations.push( `Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`, @@ -572,7 +598,56 @@ class NativeMacOSClient implements AnalysisClient { limitations.push( "The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.", ); - return ok({ captures, limitations, mixed }); + return ok({ captures, limitations, mixed, signed: signed.length > 0 }); + } + + /** + * Canonical posture of one signed slice: sorted CodeDirectory flag names and + * entitlements. Null when the slice display cannot be parsed; the slice + * still counts as signed from its zero exit code. + */ + async #slicePosture( + architecture: string, + display: NativeCommandCapture, + signal?: AbortSignal, + ): Promise< + Result< + { + readonly posture: string | null; + readonly capture: NativeCommandCapture; + }, + AnalysisError + > + > { + const entitlements = await this.#run( + "inspect_signature", + "codesign", + ["-d", "--entitlements", ":-", "-a", architecture, this.target.path], + { signal, acceptNonZero: true }, + ); + if (!entitlements.ok) return entitlements; + const parsed = parseCodeSignature(display.stderr, false, [ + this.target.path, + ]); + const directory = parsed.code_directory; + if ( + directory?.flags === null || + directory === null || + entitlements.value.exitCode !== 0 + ) + return ok({ posture: null, capture: entitlements.value }); + const entitlementValue = parseEntitlements(entitlements.value.stdout); + return ok({ + posture: canonicalJson( + { + flags: directory.flags.value, + platform: directory.platform_identifier, + entitlements: entitlementValue.value, + }, + "Signature slice posture", + ), + capture: entitlements.value, + }); } async #inspectPlist( @@ -709,6 +784,7 @@ interface MixedSignatureSlices { readonly limitations: readonly string[]; /** A signed slice coexists with an unsigned or unclassifiable one. */ readonly mixed: boolean; + readonly signed: boolean; } const isNativeOperation = ( @@ -782,7 +858,7 @@ const universalSliceArchitectures = ( const architectures = universal .split(/\s+/u) .filter((architecture) => - /^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture), + /^(?:i386|x86_64|x86_64h|armv7|arm64|arm64e)$/u.test(architecture), ); return architectures.length > 0 ? [...new Set(architectures)] : undefined; }; @@ -791,21 +867,14 @@ const codeSignArchitectures = ( format: string | null, target: BinaryTarget, ): string[] => { - const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec( - format ?? "", - )?.[1]; - if (universal !== undefined) { - const architectures = universal - .split(/\s+/u) - .filter((architecture) => - /^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture), - ); - if (architectures.length > 0) return [...new Set(architectures)]; - } - if (target.kind !== "executable" || target.format !== "mach-o") return []; - return target.availableArchitectures.map((architecture) => - architecture === "x86" ? "i386" : architecture, - ); + const declared = universalSliceArchitectures(format) ?? []; + const available = + target.kind === "executable" && target.format === "mach-o" + ? target.availableArchitectures.map((architecture) => + architecture === "x86" ? "i386" : architecture, + ) + : []; + return [...new Set([...declared, ...available])]; }; const invocation = ( diff --git a/src/native/NativeSignaturePosture.ts b/src/native/NativeSignaturePosture.ts index e768ec3231..133cc9da4b 100644 --- a/src/native/NativeSignaturePosture.ts +++ b/src/native/NativeSignaturePosture.ts @@ -143,9 +143,11 @@ export const stapledTicket = async ( try { const opened = await handle.stat(); if (!opened.isFile()) return absent; - // If the path was replaced between lstat and open, or during hashing, - // the digest would describe the wrong bytes; report it as changed. - if (opened.dev !== metadata.dev || opened.ino !== metadata.ino) + // If the path was replaced between lstat and open, or mutated while + // hashing, the digest would describe the wrong bytes: dev and ino catch + // replacement, while size and mtime catch same-inode writes. Report any + // drift as changed instead of a stale digest. + if (!sameTicketFile(opened, metadata)) return { status: "unreadable", path: relative, @@ -165,7 +167,7 @@ export const stapledTicket = async ( } signal?.throwIfAborted(); const closing = await handle.stat(); - if (closing.dev !== opened.dev || closing.ino !== opened.ino) + if (!sameTicketFile(closing, opened) || size !== opened.size) return { status: "unreadable", path: relative, @@ -178,7 +180,7 @@ export const stapledTicket = async ( // refuse a digest that no longer belongs to the bundle. try { const current = await lstat(path); - if (current.dev !== opened.dev || current.ino !== opened.ino) + if (!sameTicketFile(current, opened)) return { status: "unreadable", path: relative, @@ -222,6 +224,33 @@ export const stapledTicket = async ( } }; +/** + * Whether two observations describe the same file version. Device and inode + * catch replacement; size and mtime catch same-inode writes and truncations + * whose digest would otherwise mix multiple file states. + */ +const sameTicketFile = ( + left: { + readonly dev: number; + readonly ino: number; + readonly size: number; + readonly mtimeMs: number; + readonly ctimeMs: number; + }, + right: { + readonly dev: number; + readonly ino: number; + readonly size: number; + readonly mtimeMs: number; + readonly ctimeMs: number; + }, +): boolean => + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs; + const absent = { status: "absent", path: null, diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index 5779c2b520..fcb0d645ab 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -350,6 +350,31 @@ describe("native signature posture slice-aware cases", () => { ); }); + it("marks facets unknown when signed slices differ in posture", async () => { + const { app, executable } = await fixtureApp(undefined); + const signature = await new NativeMacOSProvider( + new UniversalPostureDiffRunner(), + "darwin", + ) + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + if (!signature.ok) throw signature.error; + const result = inspectSignatureSchema.parse(signature.value.result); + // Every slice verifies, but their CodeDirectory flags disagree. + expect(result.verification?.status).toBe("valid"); + expect( + result.security_facets.find( + ({ facet }) => facet === "library-validation", + ), + ).toMatchObject({ + state: "unknown", + evidence: ["architecture slices differ in signing state"], + }); + expect(result.limitations).toContainEqual( + expect.stringContaining("differ in CodeDirectory flags or entitlements"), + ); + }); + it("reports verification I/O failures as unknown, not invalid", async () => { const { app, executable } = await fixtureApp(undefined); const signature = await new NativeMacOSProvider( @@ -369,6 +394,44 @@ describe("native signature posture slice-aware cases", () => { }); }); +describe("signature architecture coverage", () => { + it("probes a valid x86_64h subtype rather than dropping it", async () => { + const { app, executable } = await fixtureApp(undefined); + const signature = await new NativeMacOSProvider( + new UniversalMixedRunner("x86_64h"), + "darwin", + ) + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + if (!signature.ok) throw signature.error; + const result = inspectSignatureSchema.parse(signature.value.result); + expect(result.limitations).toContain("Unsigned Mach-O slices: x86_64h."); + expect( + result.security_facets.every(({ state }) => state === "unknown"), + ).toBe(true); + }); + + it("retains per-slice entitlement captures used to compare posture", async () => { + const { app, executable } = await fixtureApp(undefined); + const signature = await new NativeMacOSProvider( + new UniversalPostureDiffRunner(), + "darwin", + ) + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + if (!signature.ok) throw signature.error; + const result = inspectSignatureSchema.parse(signature.value.result); + const commands = result.provenance.filter( + ({ command }) => + command.includes("-a") && command.includes("--entitlements"), + ); + expect(commands).toHaveLength(2); + expect(result.entitlements).toEqual({ + "com.apple.security.app-sandbox": true, + }); + }); +}); + /** A signed bundle whose nested helper is unsigned. */ class UnsignedNestedRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) { @@ -452,12 +515,16 @@ class NestedOnlyFailureRunner extends FixtureRunner { * slice while the x86_64 slice is unsigned. */ class UniversalMixedRunner extends FixtureRunner { + constructor(private readonly foreignArchitecture = "x86_64") { + super(); + } override async run(tool: string, arguments_: readonly string[]) { const result = await super.run(tool, arguments_); if (!result.ok || tool !== "codesign") return result; const architectureIndex = arguments_.indexOf("-a"); if (architectureIndex >= 0) { - if (arguments_[architectureIndex + 1] !== "x86_64") return result; + if (arguments_[architectureIndex + 1] !== this.foreignArchitecture) + return result; const stderr = `${arguments_.at(-1)}: code object is not signed at all\n`; return ok({ ...result.value, @@ -471,7 +538,7 @@ class UniversalMixedRunner extends FixtureRunner { if (!arguments_.includes("--verbose=4")) return result; const stderr = result.value.stderr.replace( /^Format=.*$/mu, - "Format=Mach-O universal (x86_64 arm64)", + `Format=Mach-O universal (${this.foreignArchitecture} arm64)`, ); return ok({ ...result.value, @@ -498,6 +565,39 @@ class CarriageReturnPathRunner extends FixtureRunner { } } +/** + * A universal binary whose slices all verify but carry different + * CodeDirectory flags (hardened arm64, plain x86_64). + */ +class UniversalPostureDiffRunner extends FixtureRunner { + 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("--entitlements")) return result; + const architectureIndex = arguments_.indexOf("-a"); + const architecture = + architectureIndex < 0 ? null : arguments_[architectureIndex + 1]; + let stderr = result.value.stderr; + if (architecture === null) + stderr = stderr.replace( + /^Format=.*$/mu, + "Format=Mach-O universal (x86_64 arm64)", + ); + else if (architecture === "x86_64") + stderr = stderr.replace( + /flags=0x[0-9a-f]+\([^)]*\)/u, + "flags=0x2(adhoc)", + ); + return ok({ + ...result.value, + stdout: "", + stderr, + stdoutBytes: 0, + stderrBytes: Buffer.byteLength(stderr), + }); + } +} + /** Verification fails because nested code cannot be read. */ class PermissionDeniedRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) {