diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index e48422cc7e..3b3b565d01 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -667,6 +667,10 @@ class NativeMacOSClient implements AnalysisClient { binding: SignatureTargetBinding, signal?: AbortSignal, ): Promise> { + const executablePaths = [ + binding.snapshotPath, + ...(await canonicalPath(binding.snapshotPath)), + ]; const display = await this.#captureSignature( binding, ["-d", "--verbose=4"], @@ -676,10 +680,12 @@ class NativeMacOSClient implements AnalysisClient { const unsigned = isNonzeroUnsignedObservation( display.value, binding.snapshotPath, + executablePaths, ); const displayFailure = codeSignCaptureFailure( display.value, binding.snapshotPath, + executablePaths, ); if (displayFailure !== null) return err(displayFailure); const requirements = await this.#captureSignature( @@ -691,6 +697,7 @@ class NativeMacOSClient implements AnalysisClient { const requirementsFailure = codeSignCaptureFailure( requirements.value, binding.snapshotPath, + executablePaths, ); if (requirementsFailure !== null) return err(requirementsFailure); const entitlements = await this.#captureSignature( @@ -702,13 +709,15 @@ class NativeMacOSClient implements AnalysisClient { const entitlementsFailure = codeSignCaptureFailure( entitlements.value, binding.snapshotPath, + executablePaths, ); if (entitlementsFailure !== null) return err(entitlementsFailure); // codesign echoes the resolved executable path in its diagnostics. - const parsed = parseCodeSignature(display.value.stderr, unsigned, [ - binding.snapshotPath, - ...(await canonicalPath(binding.snapshotPath)), - ]); + const parsed = parseCodeSignature( + display.value.stderr, + unsigned, + executablePaths, + ); // The requirement is printed to stdout; stderr echoes the path. const requirementText = /designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null; @@ -727,49 +736,79 @@ class NativeMacOSClient implements AnalysisClient { ]; const mixedSigning = !unsigned && - (isNonzeroUnsignedObservation(requirements.value, binding.snapshotPath) || - isNonzeroUnsignedObservation(entitlements.value, binding.snapshotPath)); + (isNonzeroUnsignedObservation( + requirements.value, + binding.snapshotPath, + executablePaths, + ) || + isNonzeroUnsignedObservation( + entitlements.value, + binding.snapshotPath, + executablePaths, + )); + const observation = () => + inspectSignatureSchema.parse({ + ...parsed, + designated_requirement: requirementText, + entitlements: entitlementValue.value, + provenance: captures.map((capture) => + invocation(capture, binding.snapshotPath), + ), + limitations, + }); if (mixedSigning) { + limitations.push( + "Partial signature coverage: aggregate signing metadata describes codesign’s default slice; Mach-O slices have mixed signing states.", + ); const slices = await this.#inspectMixedSignatureSlices( binding, parsed.format, + executablePaths, + { captures, limitations }, { requirements: requirements.value.exitCode, entitlements: entitlements.value.exitCode, }, signal, ); - if (!slices.ok) return slices; - captures.push(...slices.value.captures); - limitations.push(...slices.value.limitations); + if (!slices.ok) { + limitations.push( + "Architecture-specific signature inspection did not complete; remaining slice signing states are unknown.", + ); + return err(slices.error.retainPartialObservation(observation())); + } } - const provenance = captures.map((capture) => - invocation(capture, binding.snapshotPath), - ); const version = await verifySignatureTarget(this.target, binding, signal); - if (!version.ok) return version; - const result = inspectSignatureSchema.parse({ - ...parsed, - designated_requirement: requirementText, - entitlements: entitlementValue.value, - provenance, - limitations, - }); - return ok(result); + if (!version.ok) + return err(version.error.retainPartialObservation(observation())); + return ok(observation()); } async #inspectMixedSignatureSlices( binding: SignatureTargetBinding, format: string | null, + executablePaths: readonly string[], + observation: { captures: NativeCommandCapture[]; limitations: string[] }, aggregateExits: Readonly<{ requirements: number | null; entitlements: number | null; }>, signal?: AbortSignal, - ): Promise> { - const captures: NativeCommandCapture[] = []; + ): Promise> { + const { captures, limitations } = observation; const unsigned: string[] = []; - const unclassified: string[] = []; + const unsignedNote = limitations.length; + limitations.push( + "One or more Mach-O slices are unsigned, but their architectures could not be classified.", + ); + if (aggregateExits.requirements !== 0) + limitations.push( + "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", + ); + if (aggregateExits.entitlements !== 0) + limitations.push( + "The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.", + ); for (const architecture of codeSignArchitectures(format, this.target)) { const slice = await this.#captureSignature( binding, @@ -779,28 +818,17 @@ class NativeMacOSClient implements AnalysisClient { if (!slice.ok) return slice; captures.push(slice.value); if (slice.value.exitCode === 0) continue; - if (isNonzeroUnsignedObservation(slice.value, binding.snapshotPath)) - unsigned.push(architecture); - else unclassified.push(architecture); + const failure = codeSignCaptureFailure( + slice.value, + binding.snapshotPath, + executablePaths, + ); + if (failure !== null) return err(failure); + unsigned.push(architecture); + limitations[unsignedNote] = + `Unsigned Mach-O slices: ${unsigned.join(", ")}.`; } - 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 (aggregateExits.requirements !== 0) - limitations.push( - "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", - ); - if (aggregateExits.entitlements !== 0) - limitations.push( - "The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.", - ); - return ok({ captures, limitations }); + return ok(undefined); } async #captureSignature( @@ -1131,11 +1159,6 @@ interface NativeObservation { readonly locations: readonly EvidenceLocation[]; } -interface MixedSignatureSlices { - readonly captures: readonly NativeCommandCapture[]; - readonly limitations: readonly string[]; -} - const isNativeOperation = ( operation: AnalysisOperation, ): operation is NativeToolName => @@ -1318,13 +1341,15 @@ const translateCodeSignExitFailure = ( ); /** - * Accept only codesign's exact unsigned diagnostic for the bound snapshot. + * Accept only codesign's exact unsigned diagnostic for the bound snapshot, + * optionally preceded by its verified Executable path echo. * The operand is framed byte-for-byte so caller-controlled path text cannot * supply the phrase that determines signature state. */ const isNonzeroUnsignedObservation = ( capture: NativeCommandCapture, snapshotPath: string, + executablePaths: readonly string[], ): boolean => { if ( capture.exitCode === null || @@ -1335,8 +1360,16 @@ const isNonzeroUnsignedObservation = ( ) return false; const diagnostic = `${snapshotPath}: code object is not signed at all`; - return [diagnostic, `${diagnostic}\n`, `${diagnostic}\r\n`].includes( - capture.stderr, + return [ + "", + ...executablePaths.flatMap((path) => [ + `Executable=${path}\n`, + `Executable=${path}\r\n`, + ]), + ].some((prefix) => + [diagnostic, `${diagnostic}\n`, `${diagnostic}\r\n`].some( + (body) => capture.stderr === `${prefix}${body}`, + ), ); }; @@ -1351,8 +1384,10 @@ const canonicalPath = async (path: string): Promise => { const codeSignCaptureFailure = ( capture: NativeCommandCapture, snapshotPath: string, + executablePaths: readonly string[], ): AnalysisError | null => - capture.exitCode === 0 || isNonzeroUnsignedObservation(capture, snapshotPath) + capture.exitCode === 0 || + isNonzeroUnsignedObservation(capture, snapshotPath, executablePaths) ? null : translateCodeSignExitFailure(capture); diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index 5e1824e802..cee8136c3c 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js"; +import { realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { @@ -96,7 +97,7 @@ it("reports unsigned slices when a universal Mach-O has mixed signatures", async ); const client = new NativeMacOSProvider( {}, - signatureRunner({ signed, unsigned, entitlements }), + signatureRunner({ signed, unsigned, entitlements, unsignedHeader: true }), "darwin", ).createClient(await signatureTarget(signedArchitecture)); @@ -110,6 +111,7 @@ it("reports unsigned slices when a universal Mach-O has mixed signatures", async designated_requirement: null, entitlements: { "com.apple.security.app-sandbox": true }, limitations: expect.arrayContaining([ + "Partial signature coverage: aggregate signing metadata describes codesign’s default slice; Mach-O slices have mixed signing states.", "Unsigned Mach-O slices: x86_64.", "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", ]), @@ -155,6 +157,99 @@ it("rejects nonzero supplemental codesign output that is not an unsigned observa }); }); +it.each([ + "extra diagnostic", + "foreign executable", + "slice diagnostic", + "later slice diagnostic", +])("rejects an unsigned response with %s", async (kind) => { + const signed = (await fixture("codesign.txt")).replace( + /^Format=.*$/mu, + "Format=Mach-O universal (x86_64 arm64)", + ); + const replay = signatureRunner({ + signed, + unsigned: "code object is not signed at all", + entitlements: await fixture("entitlements.xml"), + unsignedHeader: true, + }); + const runner: NativeCommandRunner = { + async run(tool, args, options) { + const result = await replay.run(tool, args, options); + if (!result.ok) return result; + if (kind === "later slice diagnostic") { + if (!args.includes("arm64")) return result; + const stderr = "later architecture probe failed\n"; + return ok({ + ...result.value, + exitCode: 1, + stderr, + stderrBytes: Buffer.byteLength(stderr), + }); + } + if (result.value.exitCode === 0) return result; + const isSlice = args.includes("-a"); + if ((kind === "slice diagnostic") !== isSlice) return result; + const stderr = + kind === "foreign executable" + ? result.value.stderr.replace( + /^Executable=.*\n/u, + "Executable=/foreign/program\n", + ) + : `${result.value.stderr}additional codesign failure\n`; + return ok({ + ...result.value, + stderr, + stderrBytes: Buffer.byteLength(stderr), + }); + }, + }; + const result = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(await signatureTarget()) + .execute("inspect_signature", {}); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ProviderAdapterError" }, + }); + if (kind.endsWith("slice diagnostic") && !result.ok) { + const partial = inspectSignatureSchema.parse( + result.error.partialObservation, + ); + expect(partial).toMatchObject({ + signed: true, + identifier: "com.example.fixture", + entitlements: { "com.apple.security.app-sandbox": true }, + designated_requirement: null, + limitations: expect.arrayContaining([ + expect.stringContaining("Partial signature coverage"), + expect.stringContaining("did not complete"), + ]), + }); + expect(partial.provenance).toHaveLength( + kind === "later slice diagnostic" ? 5 : 4, + ); + expect(partial.provenance[0]?.command).toContain("--verbose=4"); + expect(partial.provenance[1]?.command).toContain("-r-"); + expect(partial.provenance[2]?.command).toContain("--entitlements"); + if (kind === "later slice diagnostic") { + expect(partial.limitations).toEqual( + expect.arrayContaining([ + "Unsigned Mach-O slices: x86_64.", + "The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.", + ]), + ); + expect(partial.provenance[4]).toMatchObject({ + command: expect.arrayContaining(["-a", "arm64"]), + exit: { code: 1, signal: null }, + }); + } + expect(partial.provenance[3]).toMatchObject({ + command: expect.arrayContaining(["-a", "x86_64"]), + exit: { code: 1, signal: null }, + }); + } +}); + it("retains native inventory facts from captured Apple tool output", async () => { const outputs: Record = {}; for (const tool of ["lipo", "otool", "nm", "dwarfdump", "vtool"]) @@ -403,8 +498,9 @@ const signatureRunner = (outputs: { readonly signed: string; readonly unsigned: string; readonly entitlements: string; + readonly unsignedHeader?: boolean; }): NativeCommandRunner => ({ - run( + async run( tool: string, arguments_: readonly string[], options: NativeCommandOptions, @@ -424,7 +520,7 @@ const signatureRunner = (outputs: { selectedResponse.output.includes("code object is not signed at all") ? { ...selectedResponse, - output: `${arguments_.at(-1)}: code object is not signed at all\n`, + output: `${outputs.unsignedHeader ? `Executable=${await realpath(arguments_.at(-1)!)}\n` : ""}${arguments_.at(-1)}: code object is not signed at all\n`, } : selectedResponse; const capture: NativeCommandCapture = {