diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 2995612460..5e1bb6a0d8 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -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([ diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index b0eb291f6d..89f8607b95 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -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