From a250696cacf0e236bbff6133403e1fffc16d5dd8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:09:46 -0800 Subject: [PATCH] fix(audit): charge sibling map reads to the map count Every generic .map beside a selected file got a full 1 MiB read and a parse, with no cumulative bound, so the 4,096-entry listing allowed about 4 GiB of reading for one small selection. Move the slot rule into chargeMapRead, which examineMap and sibling classification now share. Past the count a sibling reads only the header: a linker map is still identified, and one that may be a source map stays a skipped companion. --- docs/release-exposure.md | 2 +- src/artifacts/ReleaseExposureReader.test.ts | 91 +++++++++++++++++++++ src/artifacts/ReleaseExposureReader.ts | 82 ++++++++++++------- 3 files changed, 144 insertions(+), 31 deletions(-) diff --git a/docs/release-exposure.md b/docs/release-exposure.md index afdd87b2b0..302332d199 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 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. +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 sibling `.map` beside a selected file spends the same allowance, after the selected artifact has been read, and past it only the bounded header is read. A sibling whose header can begin a JSON object is then counted as skipped. 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 ff8b1ac451..4b9a8a8d17 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -1347,6 +1347,97 @@ describe("ReleaseExposureReader source-map count", () => { }); }); +describe("ReleaseExposureReader sibling map allowance", () => { + const countSiblingReads = async ( + run: () => Promise, + ): Promise<{ scanned: ReleaseExposureScan; budgets: number[] }> => { + const budgets: number[] = []; + const real = stableRead.impl; + stableRead.impl = async (...args) => { + if (real === undefined) throw new Error("stable read is not installed"); + if (args[0].endsWith(".map")) budgets.push(args[1]); + return real(...args); + }; + try { + return { scanned: await run(), budgets }; + } finally { + stableRead.impl = real; + } + }; + + it("stops full sibling map reads at the map count", async () => { + const root = await createTestTempDirectory("rea-release-sibling-budget-"); + const extra = 3; + const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + extra; + await Promise.all( + Array.from({ length: count }, (_, index) => + writeFile( + join(root, `s${String(index)}.map`), + JSON.stringify({ version: 3, sources: [], mappings: "" }), + ), + ), + ); + await writeFile(join(root, "app"), "plain file"); + const { scanned, budgets } = await countSiblingReads(() => + inspect(join(root, "app")), + ); + expect(budgets).toHaveLength(count); + expect( + budgets.filter( + (bytes) => bytes === MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, + ), + ).toHaveLength(MAX_RELEASE_EXPOSURE_SOURCE_MAPS); + expect( + budgets.filter( + (bytes) => bytes === MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, + ), + ).toHaveLength(extra); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: extra }); + expect(scanned.source_maps).toMatchObject({ skipped: 0, samples: [] }); + }); + + it("settles sibling maps from the probe once the selected file spends the count", async () => { + const root = await createTestTempDirectory("rea-release-sibling-spent-"); + const map = Buffer.from( + JSON.stringify({ version: 3, sources: [], mappings: "" }), + ); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture( + Array.from( + { length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS }, + (_, index) => ({ + path: `m${String(index)}.js.map`, + bytes: map, + compressed: true, + }), + ), + ).bytes, + ); + const pastProbe = MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES + 1; + const linker = "Archive member included to satisfy reference\n"; + await writeFile(join(root, "short-linker.map"), linker); + await writeFile( + join(root, "long-linker.map"), + linker.padEnd(pastProbe, " "), + ); + await writeFile(join(root, "short-json.map"), map); + await writeFile(join(root, "long-json.map"), "{".padEnd(pastProbe, " ")); + const { scanned, budgets } = await countSiblingReads(() => + inspect(join(root, "app.dll")), + ); + expect(budgets).toEqual( + Array.from({ length: 4 }, () => MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES), + ); + expect([...scanned.debug_companions.paths].sort()).toEqual([ + join(root, "long-linker.map"), + join(root, "short-linker.map"), + ]); + expect(scanned.debug_companions.skipped).toBe(2); + 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 d01aad42c4..1d3cc0d3c3 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -927,24 +927,32 @@ const examineMapMember = async ( return loaded?.kind !== "unread"; }; +/** + * Take a slot from the map count and return the bytes this read may use. The + * read is what the count bounds, so it is charged before the file is opened: + * an oversized or unreadable map spends its slot like a parsed one. With no + * slot left, only the probe is read, which can still settle a linker map. + */ +const chargeMapRead = ( + state: ScanState, +): { hasSlot: boolean; bytes: number } => { + if (state.mapsRead >= MAX_RELEASE_EXPOSURE_SOURCE_MAPS) + return { hasSlot: false, bytes: MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES }; + state.mapsRead += 1; + return { hasSlot: true, bytes: MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES }; +}; + /** Returns the read so a caller can also scan its text, or null when nothing was read. */ const examineMap = async ( state: ScanState, map: Pick, ): Promise => { - const hasSlot = state.mapsRead < MAX_RELEASE_EXPOSURE_SOURCE_MAPS; + const { hasSlot, bytes } = chargeMapRead(state); if (!hasSlot && isJavaScriptSourceMapName(map.logical)) { 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 - : MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, - ); + const loaded = await map.read(bytes); if (loaded.kind === "unread") noteUnreadMap(state, map, null); else if (loaded.kind === "over_allowance") noteUnreadMap(state, map, loaded.prefix); @@ -2106,27 +2114,41 @@ const scanSiblings = async ( } if (!entry.name.toLowerCase().endsWith(".map") || !entry.isFile()) continue; if (isJavaScriptSourceMapName(entry.name)) continue; - // Siblings are in scope only for debug-companion discovery. - const loaded = await readDiskMember( - sibling, - MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, - undefined, - state, - input, - ); - if (loaded.kind === "unread") state.skippedCompanions += 1; - else if (loaded.kind === "over_allowance") { - if (loaded.prefix === null || mayHaveSourceMapPrefix(loaded.prefix)) - state.skippedCompanions += 1; - else state.companionPaths.add(sibling); - } else { - try { - inspectSourceMap(loaded.bytes.toString("utf8")); - } catch { - if (mayHaveSourceMapPrefix(loaded.bytes)) state.skippedCompanions += 1; - else state.companionPaths.add(sibling); - } - } + await classifySiblingMap(sibling, state, input); + } +}; + +/** + * Siblings are in scope only for debug-companion discovery, so a sibling map + * never reaches the selected artifact's source-map counts. Its read still + * spends the same map count, or a directory of .map files would multiply the + * byte allowance by the listing allowance. + */ +const classifySiblingMap = async ( + sibling: string, + state: ScanState, + input: ConsumeInput, +): Promise => { + const { hasSlot, bytes } = chargeMapRead(state); + const loaded = await readDiskMember(sibling, bytes, undefined, state, input); + if (loaded.kind === "unread") { + state.skippedCompanions += 1; + return; + } + const prefix = loaded.kind === "bytes" ? loaded.bytes : loaded.prefix; + if (loaded.kind === "bytes" && hasSlot && parsesAsSourceMap(loaded.bytes)) + return; + if (prefix === null || mayHaveSourceMapPrefix(prefix)) + state.skippedCompanions += 1; + else state.companionPaths.add(sibling); +}; + +const parsesAsSourceMap = (bytes: Buffer): boolean => { + try { + inspectSourceMap(bytes.toString("utf8")); + return true; + } catch { + return false; } };