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.
This commit is contained in:
Matt Van Horn committed 2026-11-01 21:09:46 -08:00
1 parent 6f0b971639
commit a250696cac
3 files changed
+144 -31

No files matched your search

+1 -1
View File
@@ -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
@@ -1347,6 +1347,97 @@ describe("ReleaseExposureReader source-map count", () => {
});
});
describe("ReleaseExposureReader sibling map allowance", () => {
const countSiblingReads = async (
run: () => Promise<ReleaseExposureScan>,
): 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<string> => {
+52 -30
View File
@@ -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<MemberView, "logical" | "location" | "read">,
): Promise<MemberRead | null> => {
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<void> => {
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;
}
};