diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 697a1fd8f..3d2631394 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { parseBinary } from "plist"; import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; -import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; +import { + omittedPrototypeKeysLimitation, + parseXmlPropertyList, +} from "../domain/propertyListKeys.js"; import { decodeNibArchive, type NibArchiveDocument, @@ -34,6 +37,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const documents: InterfaceBuilderDocumentInput[] = []; const invalid: string[] = []; const incompleteHierarchies = new Map(); + const prototypeKeyOmissions = new Map(); let omitted = 0; let attempted = 0; try { @@ -51,9 +55,14 @@ export const analyzeInterfaceBuilderBundle = async (input: { bytes.subarray(0, 10).toString("ascii") === "NIBArchive" ? projectNibArchive(decodeNibArchive(bytes)) : null; - const raw = nib === null ? decodePlist(bytes) : nib.raw; + const { value: raw, omittedPrototypeKeys } = + nib === null + ? decodePlist(bytes) + : { value: nib.raw, omittedPrototypeKeys: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); + if (omittedPrototypeKeys > 0) + prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); const documentHash = createHash("sha256").update(bytes).digest("hex"); documents.push({ relativePath: entry.path, @@ -81,6 +90,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { }); return finalizeHierarchyCoverage(result, { incompleteHierarchies, + prototypeKeyOmissions, invalid, attempted, omitted, @@ -91,12 +101,15 @@ const finalizeHierarchyCoverage = ( result: ReturnType, counts: { incompleteHierarchies: ReadonlyMap; + prototypeKeyOmissions: ReadonlyMap; invalid: readonly string[]; attempted: number; omitted: number; }, ) => { - const { incompleteHierarchies, invalid } = counts; + const { incompleteHierarchies, prototypeKeyOmissions, invalid } = counts; + const archiveDecodePartial = + invalid.length > 0 || prototypeKeyOmissions.size > 0; return { ...result, documents: result.documents.map((document) => ({ @@ -123,9 +136,15 @@ const finalizeHierarchyCoverage = ( }), { facet: "archive_decode", - status: - invalid.length > 0 ? ("partial" as const) : ("complete" as const), - reason: invalid.length > 0 ? "one_or_more_archives_invalid" : null, + status: archiveDecodePartial + ? ("partial" as const) + : ("complete" as const), + reason: + invalid.length > 0 + ? "one_or_more_archives_invalid" + : prototypeKeyOmissions.size > 0 + ? "dictionary_entries_omitted" + : null, examined: counts.attempted, omitted: counts.omitted, }, @@ -133,7 +152,7 @@ const finalizeHierarchyCoverage = ( truncated: result.graph.truncated || counts.omitted > 0 || - invalid.length > 0 || + archiveDecodePartial || incompleteHierarchies.size > 0, }, limitations: [ @@ -149,6 +168,9 @@ const finalizeHierarchyCoverage = ( : [ `Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`, ]), + ...[...prototypeKeyOmissions].map( + ([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`, + ), ], }; }; @@ -370,10 +392,12 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; -const decodePlist = (bytes: Buffer): JsonValue => { - const parsed = +const decodePlist = ( + bytes: Buffer, +): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => { + const { value, omittedPrototypeKeys } = bytes.subarray(0, 8).toString("ascii") === "bplist00" - ? parseBinary(bytes) - : parseXmlPropertyList(bytes.toString("utf8")).value; - return jsonValueSchema.parse(parsed); + ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } + : parseXmlPropertyList(bytes.toString("utf8")); + return { value: jsonValueSchema.parse(value), omittedPrototypeKeys }; }; diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index e54e57472..759c3e454 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -39,6 +39,16 @@ it("keeps __proto__ text in strings, comments, and similar keys", () => { }); }); +it("keeps an entity-encoded key that decodes to the placeholder", () => { + const text = plist( + "__rea_prototype_key__kept__proto__x", + ); + expect(parseXmlPropertyList(text)).toEqual({ + value: { __rea_prototype_key__: "kept" }, + omittedPrototypeKeys: 1, + }); +}); + it("omits own __proto__ entries that JSON decoding creates", () => { const decoded: unknown = JSON.parse( '{"__proto__":{"x":1},"a":[{"__proto__":2,"b":3}]}', diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index bd09fd44b..c2192f00b 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -75,8 +75,11 @@ const decodeXmlText = (text: string): string => export const parseXmlPropertyList = ( text: string, ): PropertyListWithoutPrototypeKeys => { + // The decoded text holds every key the decoder can produce, including + // entity-encoded spellings of the placeholder, so no source key aliases it. + const decoded = decodeXmlText(text); let placeholder = "__rea_prototype_key__"; - while (text.includes(placeholder)) placeholder = `_${placeholder}`; + while (decoded.includes(placeholder)) placeholder = `_${placeholder}`; const substituted = text.replace( KEY_TOKEN, (token, plain?: string, cdata?: string) => diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 78d0b2bb7..7d365151d 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -34,7 +34,10 @@ 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 { parseXmlPropertyList } from "../domain/propertyListKeys.js"; +import { + omittedPrototypeKeysLimitation, + parseXmlPropertyList, +} from "../domain/propertyListKeys.js"; import { demangleSwiftSchema, inspectPlistSchema, @@ -333,7 +336,14 @@ class NativeMacOSClient implements AnalysisClient { // 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]; + const limitations = [ + ...parsed.limitations, + ...(entitlementValue.omittedPrototypeKeys === 0 + ? [] + : [ + `Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`, + ]), + ]; const mixedSigning = !unsigned && (isNonzeroUnsignedObservation(requirements.value) || @@ -355,7 +365,7 @@ class NativeMacOSClient implements AnalysisClient { const result = inspectSignatureSchema.parse({ ...parsed, designated_requirement: requirementText, - entitlements: entitlementValue, + entitlements: entitlementValue.value, provenance, limitations, }); @@ -640,13 +650,16 @@ const invocation = ( stderr_bytes: capture.stderrBytes, }); -const parseEntitlements = (output: string): JsonValue | null => { +const parseEntitlements = ( + output: string, +): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => { const start = output.indexOf(""); - if (start < 0 || end < start) return null; - return jsonValueSchema.parse( - parseXmlPropertyList(output.slice(start, end + "".length)).value, + 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 }; }; /** diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 77c8ef7aa..01e14f262 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -111,6 +111,34 @@ describe("bounded Interface Builder archive decoding", () => { expect(result.graph.truncated).toBe(true); }); + it("marks archive decoding partial when __proto__ entries are omitted", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Prototype.nib"), + '$archiverNSKeyedArchiver__proto__hidden$objects$null$top', + ); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "f".repeat(64), + }); + + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "dictionary_entries_omitted", + }), + ); + expect(result.graph.truncated).toBe(true); + expect(result.limitations).toContain( + "Contents/Resources/Prototype.nib: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.", + ); + }); + it.skipIf(process.platform !== "darwin" || !existsSync("/usr/bin/ibtool"))( "decodes an Xcode-compiled storyboard NIB and recovers its UI routes", async () => { diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 53027135a..fd265d048 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -278,6 +278,41 @@ describe("native macOS provider inspection", () => { }); }); +/** Emit entitlements whose dictionary also holds a legal `__proto__` key. */ +class PrototypeEntitlementsRunner extends FixtureRunner { + override async run(tool: string, arguments_: readonly string[]) { + const result = await super.run(tool, arguments_); + if (!result.ok || !arguments_.includes("--entitlements")) return result; + const stdout = + 'com.apple.security.app-sandbox__proto__com.apple.security.get-task-allow'; + return ok({ + ...result.value, + stdout, + stdoutBytes: Buffer.byteLength(stdout), + }); + } +} + +describe("native signature entitlements", () => { + it("reports entitlement entries keyed __proto__ that the result omits", async () => { + const client = new NativeMacOSProvider( + new PrototypeEntitlementsRunner(), + "darwin", + ).createClient(machoTarget("/private/fixture")); + + const signature = await client.execute("inspect_signature", {}); + + expect(signature.ok).toBe(true); + if (!signature.ok) return; + expect(signature.value.result).toMatchObject({ + entitlements: { "com.apple.security.app-sandbox": true }, + }); + expect(signature.value.limitations).toContain( + "Entitlements: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.", + ); + }); +}); + describe("native dispatch metadata error results", () => { it("preserves tagged cancellation and integrity failures", async () => { directory = await createTestTempDirectory("rea-dispatch-errors-");