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:
commit
15ab5dbcaf
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>__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(
|
||||
"<!-- � --><dict><key>raw</key><string><![CDATA[�]]></string><key>__proto__</key><string>x</string></dict>",
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { raw: "�" },
|
||||
omittedPrototypeKeys: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits own __proto__ entries that JSON decoding creates", () => {
|
||||
const decoded: unknown = JSON.parse(
|
||||
'{"__proto__":{"x":1},"a":[{"__proto__":2,"b":3}]}',
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
Loading…
Reference in New Issue