Merge pull request #894 from morluto/fix/plist-prototype-key-reporting

fix(plist): report omitted __proto__ entries in signatures and Interface Builder
This commit is contained in:
Kaoru0822 2026-10-07 02:12:34 -07:00 committed by GitHub
commit 15ab5dbcaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 157 additions and 23 deletions

View File

@ -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<string, number>();
const prototypeKeyOmissions = new Map<string, number>();
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<typeof buildInterfaceBuilderAnalysis>,
counts: {
incompleteHierarchies: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number>;
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 };
};

View File

@ -39,6 +39,26 @@ it("keeps __proto__ text in strings, comments, and similar keys", () => {
});
});
it("keeps an entity-encoded key that decodes to the placeholder", () => {
const text = plist(
"<dict><key>&#95;&#95;rea_prototype_key__</key><string>kept</string><key>__proto__</key><string>x</string></dict>",
);
expect(parseXmlPropertyList(text)).toEqual({
value: { __rea_prototype_key__: "kept" },
omittedPrototypeKeys: 1,
});
});
it("keeps entity-like text beyond Unicode in comments and CDATA", () => {
const text = plist(
"<!-- &#99999999999; --><dict><key>raw</key><string><![CDATA[&#x110000;]]></string><key>__proto__</key><string>x</string></dict>",
);
expect(parseXmlPropertyList(text)).toEqual({
value: { raw: "&#x110000;" },
omittedPrototypeKeys: 1,
});
});
it("omits own __proto__ entries that JSON decoding creates", () => {
const decoded: unknown = JSON.parse(
'{"__proto__":{"x":1},"a":[{"__proto__":2,"b":3}]}',

View File

@ -56,13 +56,23 @@ const XML_ENTITIES: Readonly<Record<string, string>> = {
quot: '"',
};
/**
* Expand XML character and predefined entity references. A reference beyond
* Unicode stays as written: comments and CDATA may hold such text literally,
* and the XML decoder rejects it anywhere else.
*/
const decodeXmlText = (text: string): string =>
text.replace(
/&(?:#x([\da-f]+)|#(\d+)|([a-z]+));/giu,
(entity, hex?: string, decimal?: string, name?: string) => {
if (hex !== undefined) return String.fromCodePoint(parseInt(hex, 16));
if (decimal !== undefined)
return String.fromCodePoint(parseInt(decimal, 10));
const codePoint =
hex !== undefined
? parseInt(hex, 16)
: decimal !== undefined
? parseInt(decimal, 10)
: undefined;
if (codePoint !== undefined)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
return (name === undefined ? undefined : XML_ENTITIES[name]) ?? entity;
},
);
@ -75,8 +85,12 @@ 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.
// Decoding comment or CDATA text too can only lengthen the placeholder.
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) =>

View File

@ -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("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start) return null;
return jsonValueSchema.parse(
parseXmlPropertyList(output.slice(start, end + "</plist>".length)).value,
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
};
/**

View File

@ -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"),
'<?xml version="1.0"?><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string><key>__proto__</key><string>hidden</string><key>$objects</key><array><string>$null</string></array><key>$top</key><dict/></dict></plist>',
);
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 () => {

View File

@ -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 =
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>__proto__</key><dict><key>com.apple.security.get-task-allow</key><true/></dict></dict></plist>';
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-");