fix(audit): admit binary plists in package member probes

A package member's probe judged text by its own NUL test, which accepted
byte order marks but not the binary plist signature decodeMemberText also
reads. A PyInstaller executable shipping a binary Localizable.strings was
rejected without a scan or a skipped count, so a UTF-16BE private key
marker inside it could report not_observed.

Name the encodings that explain their own NULs in one predicate, and move
the plist fixture builders to module scope.
This commit is contained in:
Matt Van Horn committed 2026-11-01 23:37:26 -08:00
1 parent 86163530af
commit 82d2ee477d
2 files changed
+56 -24

No files matched your search

+47 -23
View File
@@ -1450,6 +1450,29 @@ const bundled = async (
return root;
};
/** One dictionary whose value objects are the given encoded objects. */
const binaryPlist = (key: string, value: Buffer): Buffer => {
const objects = [
Buffer.from([0xd1, 1, 2]),
Buffer.concat([Buffer.from([0x50 | key.length]), Buffer.from(key)]),
value,
];
const table =
8 + objects.reduce((length, object) => length + object.length, 0);
const bytes = Buffer.alloc(table + objects.length + 32);
let cursor = 8;
const offsets = objects.map((object) => {
const offset = cursor;
object.copy(bytes, cursor);
cursor += object.length;
return offset;
});
return binaryPlistObjectTable(bytes, offsets, table);
};
/** Length-prefixed object body; every test value here exceeds the short form. */
const plistObject = (type: number, length: number, body: Buffer): Buffer =>
Buffer.concat([Buffer.from([(type << 4) | 0x0f, 0x10, length]), body]);
describe("ReleaseExposureReader text encodings", () => {
const marker = "-----BEGIN PRIVATE KEY-----\n";
const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(
@@ -1537,29 +1560,6 @@ describe("ReleaseExposureReader text encodings", () => {
expect(members).toEqual({ scanned: 0, skipped: 1 });
});
/** One dictionary whose value objects are the given encoded objects. */
const binaryPlist = (key: string, value: Buffer): Buffer => {
const objects = [
Buffer.from([0xd1, 1, 2]),
Buffer.concat([Buffer.from([0x50 | key.length]), Buffer.from(key)]),
value,
];
const table =
8 + objects.reduce((length, object) => length + object.length, 0);
const bytes = Buffer.alloc(table + objects.length + 32);
let cursor = 8;
const offsets = objects.map((object) => {
const offset = cursor;
object.copy(bytes, cursor);
cursor += object.length;
return offset;
});
return binaryPlistObjectTable(bytes, offsets, table);
};
/** Length-prefixed object body; every test value here exceeds the short form. */
const plistObject = (type: number, length: number, body: Buffer): Buffer =>
Buffer.concat([Buffer.from([(type << 4) | 0x0f, 0x10, length]), body]);
it.each([
["ASCII", plistObject(5, marker.length, Buffer.from(marker))],
[
@@ -1579,6 +1579,30 @@ describe("ReleaseExposureReader text encodings", () => {
expect(members).toEqual({ scanned: 1, skipped: 0 });
});
it("scans a binary plist a package member's name does not announce as text", async () => {
const root = await createTestTempDirectory("rea-release-exposure-text-");
const wide = plistObject(6, marker.length, utf16(marker, "be").subarray(2));
await writeFile(
join(root, "app.exe"),
cArchiveExecutable({
carrier: "pe",
layout: "2.1",
pythonVersion: 310,
members: [
{
name: "Localizable.strings",
typeCode: "x",
compress: true,
payload: binaryPlist("key", wide),
},
],
}),
);
const { texts, members } = await visited(root);
expect(texts.join("")).toContain(marker);
expect(members).toEqual({ scanned: 1, skipped: 0 });
});
it("keeps an undecodable binary plist's bytes in the scan, unassessed", async () => {
const root = await createTestTempDirectory("rea-release-exposure-text-");
const bytes = Buffer.concat([
+9 -1
View File
@@ -1154,7 +1154,7 @@ const examineProbedText = async (
if (prefix === null) state.skippedText += 1;
else if (
prefix.length > 0 &&
(!prefix.includes(0) || byteOrderMarkEncoding(prefix) !== null)
(!prefix.includes(0) || declaresTextEncoding(prefix))
)
await examineTextMember(state, member);
};
@@ -1361,6 +1361,14 @@ const decodeBinaryPlistMember = (bytes: Buffer): MemberText => {
}
};
/**
* Bytes that name their own text encoding, whose NULs are that encoding's
* structure rather than a sign of binary data. `decodeMemberText` reads each of
* these; a probe that admits on NULs alone would drop them unexamined.
*/
const declaresTextEncoding = (bytes: Buffer): boolean =>
byteOrderMarkEncoding(bytes) !== null || hasBinaryPlistSignature(bytes);
/**
* The text a member's bytes encode, or null when they cannot be read as text.
* A member's kind says nothing about its encoding: wide text has no byte order