From 2e9eacb8cf416dade4ea68c8b320d6b5b0fb6108 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:20:05 -0800 Subject: [PATCH] fix(audit): charge map reads to the count before opening the member An oversized or unreadable .map returned before readSourceMap counted it, so the allowance of 32 never ran out. An unexpanded .NET or PyInstaller package could then decompress every listed map member, about 1 MiB each, up to the 4,096 entry cap. The count bounds reads, so examineMap now reserves the slot before it opens the member. A map past the count is skipped unopened, and a linker map past it is still settled from its bounded prefix. An inline data: map is already in memory and keeps its charge at the point it is parsed. --- docs/release-exposure.md | 2 +- src/artifacts/ReleaseExposureReader.test.ts | 92 +++++++++++++++++++++ src/artifacts/ReleaseExposureReader.ts | 5 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/docs/release-exposure.md b/docs/release-exposure.md index 450b598c0d..bf408eb8b6 100644 --- a/docs/release-exposure.md +++ b/docs/release-exposure.md @@ -54,7 +54,7 @@ A member of a package that inventory did not expand is reported as the host file A package member skipped by a read allowance still counts as readable for `packaging.extractable_payload`, because the format stores it unencrypted. An encrypted member or one that failed to read counts as unreadable. -A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence. +A map read counts against the source-map allowance when it starts, so an oversized or unreadable `.map` uses a read as a parsed one does. A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence. ## Status diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 16bfe2ec8f..7ac8da72f8 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -33,6 +33,7 @@ import { MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES, MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, + MAX_RELEASE_EXPOSURE_SOURCE_MAPS, MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, PACKAGED_NATIVE_IMAGES, type ReleaseExposureScan, @@ -1255,6 +1256,97 @@ describe("ReleaseExposureReader member allowances", () => { }); }); +describe("ReleaseExposureReader source-map count", () => { + it.each([ + [ + "dotnet-bundle", + (bytes: Buffer, paths: readonly string[]): Buffer => + dotnetBundleFixture( + paths.map((path) => ({ path, bytes, compressed: true })), + ).bytes, + ], + [ + "pyinstaller-carchive", + (bytes: Buffer, paths: readonly string[]): Buffer => + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: paths.map((name) => ({ + name, + typeCode: "x", + compress: true, + payload: bytes, + })), + }), + ], + ])( + "charges an oversized %s map to the map count before opening it", + async (_format, archive) => { + const root = await createTestTempDirectory("rea-release-exposure-over-"); + const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 8; + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + const paths = Array.from( + { length: count }, + (_, index) => `m${String(index)}.js.map`, + ); + const target = join(root, "app.dll"); + await writeFile(target, archive(oversized, paths)); + let opened = 0; + createdReaders.wrap = (reader, path) => { + if (path !== target) return reader; + const open = reader.open.bind(reader); + reader.open = (entry, signal) => { + if (entry.path.endsWith(".js.map")) opened += 1; + return open(entry, signal); + }; + return reader; + }; + try { + const scanned = await inspect(root); + expect(opened).toBe(MAX_RELEASE_EXPOSURE_SOURCE_MAPS); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + skipped: count, + }); + expect(scanned.text_members.skipped).toBe(count); + } finally { + createdReaders.wrap = undefined; + } + }, + ); + + it("keeps a linker map's outcome past the count once oversized maps use it", async () => { + const root = await createTestTempDirectory("rea-release-exposure-spent-"); + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + oversized.write("Archive member included to satisfy reference\n"); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture( + Array.from( + { length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1 }, + (_, index) => ({ + path: `m${String(index)}.map`, + bytes: oversized, + compressed: true, + }), + ), + ).bytes, + ); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toHaveLength( + MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1, + ); + expect(scanned.source_maps.skipped).toBe(0); + }); +}); + const bundled = async ( files: readonly { path: string; bytes: Buffer }[], ): Promise => { diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 37bf99c4cb..548c808ba4 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -925,6 +925,9 @@ const examineMap = async ( state.skippedMaps += 1; return null; } + // The read is what the count bounds, so it is charged before the member is + // opened. An oversized or unreadable map spends its slot like a parsed one. + if (hasSlot) state.mapsRead += 1; const loaded = await map.read( hasSlot ? MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES @@ -1008,7 +1011,6 @@ const readSourceMap = ( map: Pick, bytes: Buffer, ): void => { - state.mapsRead += 1; try { const inspected = inspectSourceMap(bytes.toString("utf8")); const embedded = inspected.declarations.some( @@ -1173,6 +1175,7 @@ const examineInlineMaps = ( state.skippedMaps += 1; continue; } + state.mapsRead += 1; // An inline declaration is a JavaScript map even if its JSON is invalid. readSourceMap( state,