diff --git a/docs/native-investigation.md b/docs/native-investigation.md index e385ef9e5..e9b0f1b0f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -48,7 +48,16 @@ complete observation. BOM and rejects malformed byte sequences, unsupported encoding declarations, and declarations that disagree with the detected encoding. It never substitutes UTF-8 for a declared encoding it cannot process. Archive and evidence digests - still identify the original serialized bytes. + still identify the original serialized bytes. Interface Builder decoding + limits aggregate input archives to 32 MiB. + Before decoding, it also bounds the serialized structure: expanded binary + plist references, NIB table records, XML elements and text, plus their + projected representations must fit the 256 MiB aggregate decode budget. + This is a format-derived representation estimate, not a process-wide heap + ceiling. + Archives beyond either aggregate budget are omitted with their paths and an + explicitly partial `archive_decode` facet; previously decoded documents and + their identities remain available. - `inspect_native_dispatch_metadata` / `rea inspect-native-dispatch-metadata ` prefers a validated macOS Mach-O byte reader. It decodes 64-bit little-endian Objective-C class/metaclass records, superclass pointers, diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index a61da0f71..00b19fe15 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -8,6 +8,11 @@ import { parseXmlPropertyList, } from "../../domain/propertyListKeys.js"; import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js"; +import { + estimatePropertyListDecodeBytes, + InterfaceBuilderDecodeBudget, + InterfaceBuilderDecodeBudgetExceeded, +} from "./InterfaceBuilderDecodeBudget.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; import { buildInterfaceBuilderAnalysis, @@ -23,7 +28,9 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; import { decodeXmlPlistText } from "./XmlPropertyListText.js"; -const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; +const MAX_AGGREGATE_INPUT_BYTES = 32 * 1024 * 1024; +const MAX_AGGREGATE_DECODE_BYTES = 256 * 1024 * 1024; +const MAX_REPORTED_OMITTED_ARCHIVES = 64; /** Decode compiled Interface Builder archives from a local app bundle. */ export const analyzeInterfaceBuilderBundle = async (input: { @@ -36,28 +43,84 @@ export const analyzeInterfaceBuilderBundle = async (input: { const reader = new DirectoryArtifactReader(input.bundlePath); const documents: InterfaceBuilderDocumentInput[] = []; const invalid: string[] = []; + const omittedArchives: Array<{ path: string; reason: string }> = []; + let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); const prototypeKeyOmissions = new Map(); let omitted = 0; let attempted = 0; + let candidates = 0; + let aggregateInputBytes = 0; + const decodeBudget = new InterfaceBuilderDecodeBudget( + MAX_AGGREGATE_DECODE_BYTES, + ); + const recordOmittedArchive = (path: string, reason: string): void => { + if (omittedArchives.length < MAX_REPORTED_OMITTED_ARCHIVES) + omittedArchives.push({ path, reason }); + else unreportedOmittedArchives += 1; + }; try { for await (const entry of reader.entries(input.signal)) { if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path)) continue; - if (attempted >= limits.max_documents) { + candidates += 1; + if (candidates > limits.max_documents) { omitted += 1; + recordOmittedArchive(entry.path, "max_documents"); continue; } - attempted += 1; try { - const bytes = await readEntry(reader, entry, input.signal); - const nib = - bytes.subarray(0, 10).toString("ascii") === "NIBArchive" - ? projectNibArchive(decodeNibArchive(bytes)) - : null; + const remainingInputBytes = + MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes; + if ( + entry.declaredSize !== null && + entry.declaredSize > remainingInputBytes + ) { + omitted += 1; + recordOmittedArchive(entry.path, "aggregate_decode_budget_exhausted"); + continue; + } + attempted += 1; + const bytes = await readEntry( + reader, + entry, + input.signal, + remainingInputBytes, + ); + aggregateInputBytes += bytes.length; + const isNibArchive = + bytes.subarray(0, 10).toString("ascii") === "NIBArchive"; + let nib: ReturnType | null = null; + let xmlText: string | undefined; + if (isNibArchive) { + const documentBudget = new InterfaceBuilderDecodeBudget( + decodeBudget.remainingBytes, + ); + nib = projectNibArchive( + decodeNibArchive(bytes, documentBudget), + documentBudget, + limits.max_objects, + ); + decodeBudget.reserve( + documentBudget.usedBytes, + "NIBArchive representation exceeds the aggregate Interface Builder decode budget", + ); + } else { + if (bytes.subarray(0, 8).toString("ascii") !== "bplist00") + xmlText = decodeXmlPlistText(bytes); + const decodedReservation = estimatePropertyListDecodeBytes( + bytes, + decodeBudget.remainingBytes, + xmlText, + ); + decodeBudget.reserve( + decodedReservation, + "plist representation exceeds the aggregate Interface Builder decode budget", + ); + } const { value: raw, omittedPrototypeKeys } = nib === null - ? decodePlist(bytes) + ? decodePlist(bytes, xmlText) : { value: nib.raw, omittedPrototypeKeys: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); @@ -74,6 +137,11 @@ export const analyzeInterfaceBuilderBundle = async (input: { }); } catch (cause: unknown) { if (input.signal?.aborted === true) throw cause; + if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { + omitted += 1; + recordOmittedArchive(entry.path, cause.reason); + continue; + } invalid.push( `${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`, ); @@ -92,6 +160,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { incompleteHierarchies, prototypeKeyOmissions, invalid, + omittedArchives, + unreportedOmittedArchives, attempted, omitted, }); @@ -103,13 +173,23 @@ const finalizeHierarchyCoverage = ( incompleteHierarchies: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; invalid: readonly string[]; + omittedArchives: readonly { path: string; reason: string }[]; + unreportedOmittedArchives: number; attempted: number; omitted: number; }, ) => { - const { incompleteHierarchies, prototypeKeyOmissions, invalid } = counts; + const { + incompleteHierarchies, + prototypeKeyOmissions, + invalid, + omittedArchives, + unreportedOmittedArchives, + } = counts; const archiveDecodePartial = - invalid.length > 0 || prototypeKeyOmissions.size > 0; + invalid.length > 0 || + prototypeKeyOmissions.size > 0 || + omittedArchives.length > 0; return { ...result, documents: result.documents.map((document) => ({ @@ -139,12 +219,19 @@ const finalizeHierarchyCoverage = ( status: archiveDecodePartial ? ("partial" as const) : ("complete" as const), - reason: - invalid.length > 0 - ? "one_or_more_archives_invalid" - : prototypeKeyOmissions.size > 0 - ? "dictionary_entries_omitted" - : null, + reason: omittedArchives.some( + ({ reason }) => reason === "aggregate_decode_budget_exhausted", + ) + ? "aggregate_decode_budget_exhausted" + : omittedArchives.some(({ reason }) => reason === "max_documents") + ? "document_limit_reached" + : omittedArchives.length > 0 + ? "archive_decode_budget_exhausted" + : invalid.length > 0 + ? "one_or_more_archives_invalid" + : prototypeKeyOmissions.size > 0 + ? "dictionary_entries_omitted" + : null, examined: counts.attempted, omitted: counts.omitted, }, @@ -171,13 +258,70 @@ const finalizeHierarchyCoverage = ( ...[...prototypeKeyOmissions].map( ([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`, ), + ...omittedArchives.map( + ({ path, reason }) => + `${path}: omitted because ${reason.replaceAll("_", " ")}.`, + ), + ...(unreportedOmittedArchives === 0 + ? [] + : [ + `${unreportedOmittedArchives} additional omitted archive path(s) and their reasons were not retained; reporting is bounded to ${MAX_REPORTED_OMITTED_ARCHIVES} paths.`, + ]), ], }; }; /** Project decoded NIB records into the same bounded graph input as ibtool. */ -const projectNibArchive = (archive: NibArchiveDocument) => { +const projectNibArchive = ( + archive: NibArchiveDocument, + budget: InterfaceBuilderDecodeBudget, + maxHierarchyNodes: number, +) => { const byId = new Map(archive.objects.map((object) => [object.id, object])); + const decodedStrings = new Map(); + const decodedString = ( + objectId: number, + object: NibArchiveDocument["objects"][number], + ): string | null => { + if (decodedStrings.has(objectId)) + return decodedStrings.get(objectId) ?? null; + const data = object.values["NS.bytes"]; + if (typeof data !== "object" || data === null || Array.isArray(data)) { + decodedStrings.set(objectId, null); + return null; + } + const encoded = data.$nib_data_base64; + if (typeof encoded !== "string") { + decodedStrings.set(objectId, null); + return null; + } + budget.reserve( + 64 + encoded.length * 2, + "NIBArchive string projection exceeds the aggregate Interface Builder decode budget", + ); + const value = Buffer.from(encoded, "base64").toString("utf8"); + decodedStrings.set(objectId, value); + return value; + }; + const reserveStringOccurrence = (value: string | null): string | null => { + if (value !== null) + budget.reserve( + 64 + value.length * 6, + "NIBArchive repeated string projection exceeds the aggregate Interface Builder decode budget", + ); + return value; + }; + let projectedFields = 0; + for (const object of archive.objects) + projectedFields += Object.keys(object.values).length; + budget.reserve( + archive.objects.length * 128 + projectedFields * 128, + "NIBArchive graph projection exceeds the aggregate Interface Builder decode budget", + ); + budget.reserve( + archive.classes.length * 96, + "NIBArchive class projection exceeds the aggregate Interface Builder decode budget", + ); const dereference = (value: JsonValue | undefined): JsonValue | undefined => { if (typeof value !== "object" || value === null || Array.isArray(value)) return value; @@ -186,12 +330,8 @@ const projectNibArchive = (archive: NibArchiveDocument) => { const target = byId.get(ref); if (target === undefined) return undefined; if (target.class_name.replace(/\0+$/u, "") === "NSString") { - const data = target.values["NS.bytes"]; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - const encoded = data.$nib_data_base64; - if (typeof encoded === "string") - return Buffer.from(encoded, "base64").toString("utf8"); - } + const string = decodedString(ref, target); + if (string !== null) return reserveStringOccurrence(string); } return { objectID: String(ref) }; }; @@ -201,14 +341,8 @@ const projectNibArchive = (archive: NibArchiveDocument) => { const ref = value.$nib_object_ref; if (typeof ref !== "number") return null; const target = byId.get(ref); - const data = target?.values["NS.bytes"]; if (target?.class_name.replace(/\0+$/u, "") !== "NSString") return null; - if (typeof data !== "object" || data === null || Array.isArray(data)) - return null; - const encoded = data.$nib_data_base64; - return typeof encoded === "string" - ? Buffer.from(encoded, "base64").toString("utf8") - : null; + return reserveStringOccurrence(decodedString(ref, target)); }; const runtimeClass = (objectId: number): string | null => { const object = byId.get(objectId); @@ -300,6 +434,7 @@ const projectNibArchive = (archive: NibArchiveDocument) => { }); } let hierarchyOmitted = 0; + let hierarchyNodeCount = 0; const hierarchyFor = ( objectId: number, seen: Set, @@ -309,11 +444,20 @@ const projectNibArchive = (archive: NibArchiveDocument) => { hierarchyOmitted += 1; return null; } + if (hierarchyNodeCount >= maxHierarchyNodes) { + hierarchyOmitted += 1; + return null; + } + if (!budget.tryReserve(128)) { + hierarchyOmitted += 1; + return null; + } const object = byId.get(objectId); if (object === undefined) { hierarchyOmitted += 1; return null; } + hierarchyNodeCount += 1; const nextSeen = new Set(seen).add(objectId); const children: NibHierarchyNode[] = []; for (const [key, value] of Object.entries(object.values)) { @@ -352,8 +496,15 @@ const projectNibArchive = (archive: NibArchiveDocument) => { const viewHierarchy = projectNibViewHierarchy( archive.objects, new Set(Object.keys(objects).map(Number)), + () => budget.tryReserve(128), + maxHierarchyNodes, + ); + const merged = mergeNibHierarchies( + hierarchy, + viewHierarchy.hierarchy ?? [], + () => budget.tryReserve(96), + maxHierarchyNodes, ); - const merged = mergeNibHierarchies(hierarchy, viewHierarchy.hierarchy ?? []); return { omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, raw: jsonValueSchema.parse({ @@ -376,10 +527,12 @@ const readEntry = async ( reader: DirectoryArtifactReader, entry: ArtifactEntry, signal?: AbortSignal, + maxInputBytes = MAX_AGGREGATE_INPUT_BYTES, ): Promise => { - if (entry.declaredSize !== null && entry.declaredSize > MAX_DOCUMENT_BYTES) - throw new RangeError( - "archive exceeds the 64 MiB per-document decode limit", + if (entry.declaredSize !== null && entry.declaredSize > maxInputBytes) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + "archive exceeds the aggregate Interface Builder decode budget", ); const stream = await reader.open(entry, signal); const chunks: Buffer[] = []; @@ -389,10 +542,11 @@ const readEntry = async ( ? chunk : Buffer.from(chunk as Uint8Array); length += bytes.length; - if (length > MAX_DOCUMENT_BYTES) { + if (length > maxInputBytes) { stream.destroy(); - throw new RangeError( - "archive exceeded the 64 MiB per-document decode limit", + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + "archive exceeded the aggregate Interface Builder decode budget", ); } chunks.push(bytes); @@ -403,10 +557,11 @@ const readEntry = async ( /** Project decoded plist data and dates while retaining omitted-key coverage. */ const decodePlist = ( bytes: Buffer, + xmlText?: string, ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => { const { value, omittedPrototypeKeys } = bytes.subarray(0, 8).toString("ascii") === "bplist00" ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } - : parseXmlPropertyList(decodeXmlPlistText(bytes)); + : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); return { value: projectPlistValue(value).value, omittedPrototypeKeys }; }; diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts new file mode 100644 index 000000000..6ec1c82f1 --- /dev/null +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -0,0 +1,250 @@ +import { decodeXmlPlistText } from "./XmlPropertyListText.js"; + +/** Reason reported when an archive would exceed the shared decoding budget. */ +export type InterfaceBuilderBudgetReason = "aggregate_decode_budget_exhausted"; + +/** A named resource-limit failure that keeps budget exhaustion out of decode errors. */ +export class InterfaceBuilderDecodeBudgetExceeded extends Error { + constructor( + readonly reason: InterfaceBuilderBudgetReason, + message: string, + ) { + super(message); + this.name = "InterfaceBuilderDecodeBudgetExceeded"; + } +} + +/** Shared byte budget for decoder records and projected archive data. */ +export class InterfaceBuilderDecodeBudget { + readonly #initialBytes: number; + #remainingBytes: number; + + constructor(maxBytes: number) { + this.#initialBytes = maxBytes; + this.#remainingBytes = maxBytes; + } + + /** Representation bytes still available for the current inspection. */ + get remainingBytes(): number { + return this.#remainingBytes; + } + + /** Representation bytes reserved so far. */ + get usedBytes(): number { + return this.#initialBytes - this.#remainingBytes; + } + + /** Reserve representation bytes before allocation, or report exhaustion. */ + reserve(bytes: number, message: string): void { + if ( + !Number.isSafeInteger(bytes) || + bytes < 0 || + bytes > this.#remainingBytes + ) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + message, + ); + this.#remainingBytes -= bytes; + } + + /** Reserve bytes for an optional projection without throwing on exhaustion. */ + tryReserve(bytes: number): boolean { + if ( + !Number.isSafeInteger(bytes) || + bytes < 0 || + bytes > this.#remainingBytes + ) + return false; + this.#remainingBytes -= bytes; + return true; + } +} + +/** Preflight the representation created by the pinned plist decoder. */ +export const estimatePropertyListDecodeBytes = ( + bytes: Buffer, + maxBytes: number, + xmlText?: string, +): number => { + const estimate = + bytes.subarray(0, 8).toString("ascii") === "bplist00" + ? estimateBinaryPlistExpansion(bytes, maxBytes) + : estimateXmlPlistExpansion(bytes, maxBytes, xmlText); + if (estimate > maxBytes) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + "plist representation exceeds the aggregate Interface Builder decode budget", + ); + return estimate; +}; + +const estimateXmlPlistExpansion = ( + bytes: Buffer, + maxBytes: number, + xmlText?: string, +): number => { + const xml = xmlText ?? decodeXmlPlistText(bytes); + if (/]*\[/iu.test(xml)) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + "XML plist internal entity expansion exceeds the aggregate Interface Builder decode budget", + ); + const elementPattern = + /||]*>|<\?[^]*?\?>|<\/?[A-Za-z][^>]*>/giu; + const decodedSourceBytes = xml.length * 2; + let elementCount = 0; + let xmlDepth = 0; + for (const match of xml.matchAll(elementPattern)) { + const token = match[0] ?? ""; + if ( + token.startsWith("`.repeat(11 * 1024); + const valid = (name: string, body = "") => + `document${name}${body}`; + // XML data expands to a Uint8Array and then to a base64 JSON projection; + // this first valid document exercises expansion beyond the source bytes. + const expandedData = Buffer.alloc(1024 * 1024, 7).toString("base64"); + await writeFile( + join(resources, "A.nib"), + valid("A", `payload${expandedData}`), + ); + await writeFile(join(resources, "B.nib"), valid("B", padding)); + await writeFile(join(resources, "C.nib"), valid("C", padding)); + await writeFile(join(resources, "D.nib"), valid("D", padding)); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([ + "Contents/Resources/A.nib", + "Contents/Resources/B.nib", + "Contents/Resources/C.nib", + ]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "aggregate_decode_budget_exhausted", + examined: 3, + omitted: 1, + }), + ); + expect(result.limitations).toContain( + "Contents/Resources/D.nib: omitted because aggregate decode budget exhausted.", + ); + }); +}); + describe("bounded Interface Builder archive decoding", () => { it("counts malformed archives against the document limit", async () => { const root = await createTestTempDirectory("rea-ib-test-");