import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY, nativeMacOSCapabilities, } from "./NativeMacOSProviderMetadata.js"; export { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js"; import { inspectAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; import { observeNativeUi } from "./NativeUiObservation.js"; import { dirname, isAbsolute, resolve } from "node:path"; import { open, realpath, stat } from "node:fs/promises"; import { z } from "zod"; import { createAnalysisExecution, type AnalysisClient, type AnalysisOperation, type AnalysisProvider, type CapabilityDescriptor, type ProviderIdentity, } from "../application/AnalysisProvider.js"; import { NATIVE_TOOL_CONTRACTS, type NativeToolName, } from "../contracts/nativeToolContracts.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, } from "../domain/propertyListKeys.js"; import { demangleSwiftSchema, inspectPlistSchema, inspectSignatureSchema, listArchitecturesSchema, type NativeCommandInvocation, } from "../domain/nativeInspection.js"; import { err, ok, type Result } from "../domain/result.js"; import { NativeCommandFailure, XcrunCommandRunner, type NativeCommandCapture, type NativeCommandRunner, } from "./CommandRunner.js"; import { parseCodeSignature } from "./parsers/codesign.js"; import { parseDemangledSymbols } from "./parsers/demangle.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; import { parsePlistJson, parsePlistXml, plistJsonNeedsNumberTypes, } from "./parsers/plist.js"; import { architectureLocations, inspectNativeMacho, } from "./NativeMachoInspection.js"; /** Read-only semantic provider composed from Xcode command-line utilities. */ export class NativeMacOSProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[]; constructor( private readonly runner: NativeCommandRunner = new XcrunCommandRunner(), platform: NodeJS.Platform = process.platform, ) { this.#capabilities = nativeMacOSCapabilities(platform); } identity(): ProviderIdentity { return IDENTITY; } capabilities(): readonly CapabilityDescriptor[] { return this.#capabilities; } createClient(target: BinaryTarget): AnalysisClient { return new NativeMacOSClient(target, this.runner); } } class NativeMacOSClient implements AnalysisClient { constructor( private readonly target: BinaryTarget, private readonly runner: NativeCommandRunner, ) {} async execute( operation: AnalysisOperation, parameters: Readonly>, options?: { readonly signal?: AbortSignal }, ) { if (options?.signal?.aborted === true) return err(new AnalysisCancelledError(operation)); if (operation === "health") return ok(createAnalysisExecution(null, IDENTITY)); if (operation === "inspect_native_dispatch_metadata") { if (this.target.kind !== "executable" || this.target.format !== "mach-o") return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, operation, "Binary Apple metadata requires Mach-O; other providers can report symbol-only metadata", ), ); try { const maxRecords = z .number() .int() .min(1) .max(20000) .parse(parameters.max_records ?? 5000); const result = await inspectAppleDispatchMetadata( this.target, maxRecords, options?.signal, ); return ok( createAnalysisExecution(result, result.provider, { locations: [{ kind: "artifact-path", path: this.target.path }], limitations: result.result.coverage.flatMap(({ reason }) => reason === null ? [] : [reason], ), }), ); } catch (cause) { if (cause instanceof AnalysisError) return err(cause); return err( options?.signal?.aborted ? new AnalysisCancelledError(operation) : new ProviderAdapterError(IDENTITY.id, operation, { cause, diagnostics: { target_path: this.target.path, reason: cause instanceof Error ? cause.message : String(cause), }, }), ); } } if (!isNativeOperation(operation)) return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, operation, "Operation is not implemented by native macOS tools.", ), ); if ( operation === "observe_native_ui" || operation === "capture_native_ui_scenario" ) { const result = await observeNativeUi(this.target, operation, parameters, { signal: options?.signal, }); return result.ok ? ok( createAnalysisExecution(result.value, IDENTITY, { limitations: result.value.limitations, }), ) : result; } try { const observation = await this.#dispatch( operation, parameters, options?.signal, ); return observation.ok ? ok( createAnalysisExecution(observation.value.result, IDENTITY, { rawResult: { provenance: observation.value.provenance }, limitations: observation.value.limitations, locations: observation.value.locations, }), ) : observation; } catch (cause: unknown) { return err( new AnalysisOutputError(operation, "Native output parsing failed", { cause, }), ); } } close(): Promise { return Promise.resolve(); } #dispatch( operation: NativeToolName, parameters: Readonly>, signal?: AbortSignal, ): Promise> { switch (operation) { case "observe_native_ui": case "capture_native_ui_scenario": return Promise.resolve( err( new AnalysisCapabilityUnavailableError( IDENTITY.id, operation, "UI observation uses its explicit authority boundary", ), ), ); case "inspect_macho": return this.#inspectMacho(signal); case "inspect_signature": return this.#inspectSignature(signal); case "inspect_plist": return this.#inspectPlist(parameters, signal); case "list_architectures": return this.#listArchitectures(signal); case "demangle_swift": return this.#demangle(parameters, signal); } } async #listArchitectures( signal?: AbortSignal, ): Promise> { const capture = await this.#run( "list_architectures", "lipo", ["-detailed_info", this.target.path], { signal }, ); if (!capture.ok) return capture; const provenance = [invocation(capture.value, this.target.path)]; const architectures = parseLipoArchitectures(capture.value.stdout); const result = listArchitecturesSchema.parse({ architectures: { items: architectures, total: architectures.length, exhaustive: true, limitations: [], }, provenance, limitations: [], }); return ok({ result: jsonValueSchema.parse(result), provenance, limitations: [], locations: architectureLocations(result.architectures.items), }); } async #demangle( parameters: Readonly>, signal?: AbortSignal, ): Promise> { const symbols = z.array(z.string()).safeParse(parameters.symbols); if (!symbols.success) return err( new AnalysisOutputError( "demangle_swift", "symbols were not parsed strings", ), ); const capture = await this.#run( "demangle_swift", "swift-demangle", // `--` keeps symbols that begin with `-` from selecting demangler options. ["--compact", "--", ...symbols.data], { signal }, ); if (!capture.ok) return capture; const provenance = [invocation(capture.value, this.target.path)]; const result = demangleSwiftSchema.parse({ symbols: parseDemangledSymbols(symbols.data, capture.value.stdout), provenance, limitations: [], }); return ok({ result: jsonValueSchema.parse(result), provenance, limitations: [], locations: [], }); } async #inspectSignature( signal?: AbortSignal, ): Promise> { const display = await this.#run( "inspect_signature", "codesign", ["-d", "--verbose=4", this.target.path], { signal, acceptNonZero: true }, ); if (!display.ok) return display; const unsigned = isNonzeroUnsignedObservation(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: 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: true }, ); if (!entitlements.ok) return entitlements; const entitlementsFailure = codeSignCaptureFailure(entitlements.value); if (entitlementsFailure !== null) return err(entitlementsFailure); // codesign echoes the resolved executable path in its diagnostics. const parsed = parseCodeSignature(display.value.stderr, unsigned, [ this.target.path, ...(await canonicalPath(this.target.path)), ]); // The requirement is printed to stdout; stderr echoes the path. const requirementText = /designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null; // Entitlements XML is printed to stdout; stderr echoes the path. const entitlementValue = parseEntitlements(entitlements.value.stdout); const captures = [display.value, requirements.value, entitlements.value]; const limitations = [ ...parsed.limitations, ...(entitlementValue.omittedPrototypeKeys === 0 ? [] : [ `Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`, ]), ]; const mixedSigning = !unsigned && (isNonzeroUnsignedObservation(requirements.value) || isNonzeroUnsignedObservation(entitlements.value)); if (mixedSigning) { const slices = await this.#inspectMixedSignatureSlices( parsed.format, 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 result = inspectSignatureSchema.parse({ ...parsed, designated_requirement: requirementText, entitlements: entitlementValue.value, provenance, limitations, }); return ok({ result: jsonValueSchema.parse(result), provenance, limitations: result.limitations, locations: [], }); } async #inspectMixedSignatureSlices( format: string | null, requirementsUnavailable: boolean, entitlementsUnavailable: boolean, signal?: AbortSignal, ): Promise> { const captures: NativeCommandCapture[] = []; const unsigned: string[] = []; const unclassified: string[] = []; for (const architecture of codeSignArchitectures(format, 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, ): Promise> { const requested = parameters.path; if (requested !== undefined && typeof requested !== "string") return err( new AnalysisOutputError("inspect_plist", "path was not a string"), ); const plist = await resolvePlistPath(this.target, requested); if (!plist.ok) return plist; const selected = await requireRegularPlist(plist.value, requested); if (!selected.ok) return selected; const classified = await this.#run( "inspect_plist", "file", ["-b", plist.value], { signal }, ); if (!classified.ok) return classified; const json = await this.#run( "inspect_plist", "plutil", ["-convert", "json", "-o", "-", "--", plist.value], { signal, acceptNonZero: true }, ); if (!json.ok) return json; // JSON cannot express data, dates, or non-finite reals; plutil rejects // such plists, so decode its lossless XML conversion instead. JSON also // prints an integral like an , so a number beyond the // exact JSON range takes its element type from the XML conversion. const jsonDecoded = json.value.exitCode === 0; const xml = jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout) ? undefined : await this.#run( "inspect_plist", "plutil", ["-convert", "xml1", "-o", "-", "--", plist.value], { signal, acceptNonZero: !jsonDecoded }, ); if (xml !== undefined && !xml.ok) return xml; if (xml !== undefined && xml.value.exitCode !== 0) return err( unreadablePlist( plist.value, requested, `plutil could not decode it as a property list (${plutilDiagnostic(xml.value.stderr, plist.value)})`, ), ); const parsed = xml === undefined ? parsePlistJson(json.value.stdout) : jsonDecoded ? parsePlistJson(json.value.stdout, xml.value.stdout) : parsePlistXml(xml.value.stdout); if (!parsed.ok) return parsed; const provenance = [ classified.value, json.value, ...(xml === undefined ? [] : [xml.value]), ].map((item) => invocation(item, plist.value, "$PLIST")); const result = inspectPlistSchema.parse({ format: /binary property list/iu.test(classified.value.stdout) ? "binary" : /XML|text/iu.test(classified.value.stdout) ? "xml" : "unknown", value: parsed.value.value, bundle: parsed.value.bundle, source_path: plist.value, provenance, limitations: parsed.value.limitations, }); return ok({ result: jsonValueSchema.parse(result), provenance, limitations: parsed.value.limitations, locations: [{ kind: "artifact-path", path: plist.value }], }); } async #inspectMacho( signal?: AbortSignal, ): Promise> { if (this.target.format !== "mach-o") return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, "inspect_macho", "Active artifact is not Mach-O.", ), ); return inspectNativeMacho({ target: this.target, ...(signal === undefined ? {} : { signal }), run: (tool, arguments_, commandSignal) => this.#run("inspect_macho", tool, arguments_, { signal: commandSignal, }), invocation: (capture) => invocation(capture, this.target.path), }); } async #run( operation: NativeToolName, tool: string, arguments_: readonly string[], options: { readonly signal?: AbortSignal | undefined; readonly acceptNonZero?: boolean; } = {}, ): Promise> { const captured = await this.runner.run(tool, arguments_, { ...(options.signal === undefined ? {} : { signal: options.signal }), acceptNonZero: options.acceptNonZero ?? false, }); if (captured.ok) return captured; return err(translateCommandFailure(operation, captured.error)); } } interface NativeObservation { readonly result: JsonValue; readonly provenance: readonly NativeCommandInvocation[]; readonly limitations: readonly string[]; readonly locations: readonly EvidenceLocation[]; } interface MixedSignatureSlices { readonly captures: readonly NativeCommandCapture[]; readonly limitations: readonly string[]; } const isNativeOperation = ( operation: AnalysisOperation, ): operation is NativeToolName => NATIVE_TOOL_CONTRACTS.some(({ name }) => name === operation); const translateCommandFailure = ( operation: NativeToolName, failure: NativeCommandFailure, ): AnalysisError => { if (failure.reason === "unavailable") return new AnalysisCapabilityUnavailableError( IDENTITY.id, operation, `${failure.tool} is unavailable through xcrun.`, ); if (failure.reason === "cancelled") return new AnalysisCancelledError(operation); 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}`; /** * A signed artifact exits zero; its diagnostics can still contain this text * through the echoed path or identifier. */ const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean => capture.exitCode !== null && capture.exitCode !== 0 && /not signed at all|code object is not signed/iu.test(commandOutput(capture)); const canonicalPath = async (path: string): Promise => { try { return [await realpath(path)]; } catch { return []; } }; const codeSignCaptureFailure = ( capture: NativeCommandCapture, ): AnalysisError | null => capture.exitCode === 0 || isNonzeroUnsignedObservation(capture) ? null : translateCodeSignExitFailure(capture); 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 invocation = ( capture: NativeCommandCapture, artifactPath: string, alias = "$ARTIFACT", ): NativeCommandInvocation => ({ tool: capture.tool, command: [ capture.executable, ...capture.arguments.map((argument) => argument === artifactPath ? alias : argument, ), ], tool_version: capture.toolVersion, version_reason: capture.versionReason, executable_sha256: capture.executableSha256, exit: { code: capture.exitCode, signal: capture.signal }, stdout_bytes: capture.stdoutBytes, stderr_bytes: capture.stderrBytes, }); const parseEntitlements = ( output: string, ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => { const start = output.indexOf(""); if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 }; const { value, omittedPrototypeKeys } = parseXmlPropertyList( output.slice(start, end + "".length), ); return { value: jsonValueSchema.parse(value), omittedPrototypeKeys }; }; /** * Report a plist that cannot be decoded as the caller's selection, or as the * target's missing default, rather than as a failed tool run. */ const unreadablePlist = ( path: string, requested: string | undefined, reason: string, ): AnalysisError => { const sentence = reason.endsWith(".") ? reason : `${reason}.`; return requested === undefined ? new AnalysisCapabilityUnavailableError( IDENTITY.id, "inspect_plist", `The target has no readable Contents/Info.plist at ${path}: ${sentence} Pass path to inspect another plist.`, ) : new AnalysisInputError("inspect_plist", undefined, [ { path: ["path"], reason: "invalid_value", message: `Cannot inspect ${path}: ${sentence}`, }, ]); }; /** plutil prefixes its diagnostic with the path the message already names. */ const plutilDiagnostic = (stderr: string, path: string): string => { const text = stderr.trim(); return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text; }; /** * Admit a readable regular file before plutil runs, so a later plutil failure * reflects the bytes rather than a missing path or a host permission denial. */ const requireRegularPlist = async ( path: string, requested: string | undefined, ): Promise> => { try { if (!(await stat(path)).isFile()) return err(unreadablePlist(path, requested, "it is not a regular file")); // Opening also observes ACL and macOS privacy denials that stat permits. await (await open(path, "r")).close(); return ok(null); } catch (cause: unknown) { const code = cause instanceof Error && "code" in cause ? cause.code : undefined; if (code === "ENOENT" || code === "ENOTDIR") return err(unreadablePlist(path, requested, "no file exists there")); if (code === "EACCES" || code === "EPERM") return err( new BinaryTargetError( path, `permission denied while reading plist (${code})`, { cause }, ), ); return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), ); } }; const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, ): Promise> => { try { const source = target.sourcePath ?? target.path; if (requested !== undefined && isAbsolute(requested)) return ok(resolve(requested)); const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); if (requested !== undefined) return ok(resolve(root, requested)); // A bundle target records the Info.plist its program file was declared // in: Contents/Info.plist, or the root plist of an iOS-style bundle. return ok(target.bundleInfoPlist ?? resolve(root, "Contents/Info.plist")); } catch (cause: unknown) { return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), ); } };