From 66f3cfa594a86cbf1f21fc6d2eca1a82e236c896 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Tue, 6 Oct 2026 04:59:15 -0700 Subject: [PATCH] fix(native): inspect mixed-signature Mach-O slices --- src/native/NativeMacOSProvider.ts | 161 +++++++++++++++--- .../native/nativeInventory.golden.test.ts | 119 +++++++++++++ 2 files changed, 253 insertions(+), 27 deletions(-) diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 4a0e4b016..f5c29c8fe 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -331,55 +331,62 @@ class NativeMacOSClient implements AnalysisClient { { signal, acceptNonZero: true }, ); if (!display.ok) return display; - const unsigned = /not signed at all|code object is not signed/iu.test( - `${display.value.stdout}\n${display.value.stderr}`, - ); - if (!unsigned && display.value.exitCode !== 0) - return err( - translateCommandFailure( - "inspect_signature", - new NativeCommandFailure( - "codesign", - "nonzero-exit", - display.value.exitCode, - ), - ), - ); + const displayOutput = commandOutput(display.value); + const unsigned = isUnsignedCodeSignObservation(display.value); + const displayFailure = codeSignCaptureFailure(display.value); + if (displayFailure !== null) return err(displayFailure); const requirements = await this.#run( "inspect_signature", "codesign", ["-d", "-r-", this.target.path], - { signal, acceptNonZero: unsigned }, + { signal, acceptNonZero: true }, ); if (!requirements.ok) return requirements; + const requirementsFailure = codeSignCaptureFailure(requirements.value); + if (requirementsFailure !== null) return err(requirementsFailure); const entitlements = await this.#run( "inspect_signature", "codesign", ["-d", "--entitlements", ":-", this.target.path], - { signal, acceptNonZero: unsigned }, + { signal, acceptNonZero: true }, ); if (!entitlements.ok) return entitlements; - const parsed = parseCodeSignature( - `${display.value.stdout}\n${display.value.stderr}`, - unsigned, - ); + const entitlementsFailure = codeSignCaptureFailure(entitlements.value); + if (entitlementsFailure !== null) return err(entitlementsFailure); + const parsed = parseCodeSignature(displayOutput, unsigned); const requirementText = /designated\s*=>\s*(.+)$/mu.exec( - `${requirements.value.stdout}\n${requirements.value.stderr}`, + commandOutput(requirements.value), )?.[1] ?? null; const entitlementValue = parseEntitlements( - `${entitlements.value.stdout}\n${entitlements.value.stderr}`, + commandOutput(entitlements.value), + ); + const captures = [display.value, requirements.value, entitlements.value]; + const limitations = [...parsed.limitations]; + const mixedSigning = + !unsigned && + (isNonzeroUnsignedObservation(requirements.value) || + isNonzeroUnsignedObservation(entitlements.value)); + if (mixedSigning) { + const slices = await this.#inspectMixedSignatureSlices( + displayOutput, + requirements.value.exitCode !== 0, + entitlements.value.exitCode !== 0, + signal, + ); + if (!slices.ok) return slices; + captures.push(...slices.value.captures); + limitations.push(...slices.value.limitations); + } + const provenance = captures.map((capture) => + invocation(capture, this.target.path), ); - const provenance = [ - display.value, - requirements.value, - entitlements.value, - ].map((capture) => invocation(capture, this.target.path)); const result = inspectSignatureSchema.parse({ ...parsed, designated_requirement: requirementText, entitlements: entitlementValue, provenance, + limitations, }); return ok({ result: jsonValueSchema.parse(result), @@ -389,6 +396,52 @@ class NativeMacOSClient implements AnalysisClient { }); } + async #inspectMixedSignatureSlices( + displayOutput: string, + requirementsUnavailable: boolean, + entitlementsUnavailable: boolean, + signal?: AbortSignal, + ): Promise> { + const captures: NativeCommandCapture[] = []; + const unsigned: string[] = []; + const unclassified: string[] = []; + for (const architecture of codeSignArchitectures( + displayOutput, + this.target, + )) { + const slice = await this.#run( + "inspect_signature", + "codesign", + ["-d", "-a", architecture, "--verbose=4", this.target.path], + { signal, acceptNonZero: true }, + ); + if (!slice.ok) return slice; + captures.push(slice.value); + if (slice.value.exitCode === 0) continue; + if (isNonzeroUnsignedObservation(slice.value)) + unsigned.push(architecture); + else unclassified.push(architecture); + } + const limitations = [ + unsigned.length > 0 + ? `Unsigned Mach-O slices: ${unsigned.join(", ")}.` + : "One or more Mach-O slices are unsigned, but their architectures could not be classified.", + ]; + if (unclassified.length > 0) + limitations.push( + `Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`, + ); + if (requirementsUnavailable) + limitations.push( + "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", + ); + if (entitlementsUnavailable) + limitations.push( + "The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.", + ); + return ok({ captures, limitations }); + } + async #inspectPlist( parameters: Readonly>, signal?: AbortSignal, @@ -485,6 +538,11 @@ interface NativeObservation { readonly locations: readonly EvidenceLocation[]; } +interface MixedSignatureSlices { + readonly captures: readonly NativeCommandCapture[]; + readonly limitations: readonly string[]; +} + const isNativeOperation = ( operation: AnalysisOperation, ): operation is NativeToolName => @@ -505,6 +563,55 @@ const translateCommandFailure = ( return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure }); }; +const translateCodeSignExitFailure = ( + capture: NativeCommandCapture, +): AnalysisError => + translateCommandFailure( + "inspect_signature", + new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode), + ); + +const commandOutput = (capture: NativeCommandCapture): string => + `${capture.stdout}\n${capture.stderr}`; + +const isUnsignedCodeSignObservation = ( + capture: NativeCommandCapture, +): boolean => + /not signed at all|code object is not signed/iu.test(commandOutput(capture)); + +const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean => + capture.exitCode !== null && + capture.exitCode !== 0 && + isUnsignedCodeSignObservation(capture); + +const codeSignCaptureFailure = ( + capture: NativeCommandCapture, +): AnalysisError | null => + capture.exitCode === 0 || isNonzeroUnsignedObservation(capture) + ? null + : translateCodeSignExitFailure(capture); + +const codeSignArchitectures = ( + displayOutput: string, + target: BinaryTarget, +): string[] => { + const universal = /^Format=Mach-O universal \(([^)\r\n]+)\)$/mu.exec( + displayOutput, + )?.[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 invocation = ( capture: NativeCommandCapture, artifactPath: string, diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index d780fe6a2..59ded77f0 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -1,4 +1,11 @@ import { expect, it } from "vitest"; +import { err, ok } from "../../../../src/domain/result.js"; +import { + NativeCommandFailure, + type NativeCommandCapture, + type NativeCommandOptions, + type NativeCommandRunner, +} from "../../../../src/native/CommandRunner.js"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js"; import { parseDyldSymbols } from "../../../../src/native/parsers/dyldInfo.js"; @@ -33,6 +40,74 @@ it("distinguishes a codesign execution failure from an observed unsigned artifac } }); +it("reports unsigned slices when a universal Mach-O has mixed signatures", async () => { + const signedFixture = await fixture("codesign.txt"); + const unsigned = await fixture("dyld-inventory/codesign-unsigned.txt"); + const entitlements = await fixture("entitlements.xml"); + for (const signedArchitecture of ["arm64", "arm64e"]) { + const signed = signedFixture.replace( + /^Format=.*$/mu, + `Format=Mach-O universal (x86_64 ${signedArchitecture})`, + ); + const client = new NativeMacOSProvider( + signatureRunner({ signed, unsigned, entitlements }), + "darwin", + ).createClient(machoTarget("/private/fixture")); + + const execution = await client.execute("inspect_signature", {}); + + expect(execution.ok).toBe(true); + if (!execution.ok) continue; + expect(execution.value.result).toMatchObject({ + signed: true, + identifier: "com.example.fixture", + designated_requirement: null, + entitlements: { "com.apple.security.app-sandbox": true }, + limitations: expect.arrayContaining([ + "Unsigned Mach-O slices: x86_64.", + "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", + ]), + provenance: expect.arrayContaining([ + expect.objectContaining({ + command: [ + "/usr/bin/codesign", + "-d", + "-a", + "x86_64", + "--verbose=4", + "$ARTIFACT", + ], + exit: { code: 1, signal: null }, + }), + expect.objectContaining({ + command: expect.arrayContaining(["-a", signedArchitecture]), + exit: { code: 0, signal: null }, + }), + ]), + }); + } +}); + +it("rejects nonzero supplemental codesign output that is not an unsigned observation", async () => { + const signed = await fixture("codesign.txt"); + const entitlements = await fixture("entitlements.xml"); + const client = new NativeMacOSProvider( + signatureRunner({ + signed, + unsigned: "/private/fixture: invalid or unsupported format\n", + entitlements, + }), + "darwin", + ).createClient(machoTarget("/private/fixture")); + + const execution = await client.execute("inspect_signature", {}); + + expect(execution).toMatchObject({ + ok: false, + error: { _tag: "ProviderAdapterError" }, + }); +}); + it("retains native inventory facts from captured Apple tool output", async () => { const outputs: Record = {}; for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"]) @@ -200,3 +275,47 @@ Section sections: [{ segment: "0001", name: "0002", size: 8 }], }); }); + +const signatureRunner = (outputs: { + readonly signed: string; + readonly unsigned: string; + readonly entitlements: string; +}): NativeCommandRunner => ({ + run( + tool: string, + arguments_: readonly string[], + options: NativeCommandOptions, + ) { + const architectureIndex = arguments_.indexOf("-a"); + const architecture = + architectureIndex < 0 ? null : arguments_[architectureIndex + 1]; + const response = arguments_.includes("-r-") + ? { output: outputs.unsigned, exitCode: 1 } + : arguments_.includes("--entitlements") + ? { output: outputs.entitlements, exitCode: 0 } + : architecture === "x86_64" + ? { output: outputs.unsigned, exitCode: 1 } + : { output: outputs.signed, exitCode: 0 }; + const capture: NativeCommandCapture = { + tool, + executable: `/usr/bin/${tool}`, + executableSha256: "a".repeat(64), + toolVersion: null, + versionReason: "fixture", + arguments: [...arguments_], + stdout: "", + stderr: response.output, + stdoutBytes: 0, + stderrBytes: Buffer.byteLength(response.output), + exitCode: response.exitCode, + signal: null, + }; + return Promise.resolve( + response.exitCode === 0 || options.acceptNonZero === true + ? ok(capture) + : err( + new NativeCommandFailure(tool, "nonzero-exit", response.exitCode), + ), + ); + }, +});