fix(audit): bound the paths kept for local source map targets

noteMapTarget kept every declared target resolved against its member's
directory, so a short URL under a long directory could retain far more
than the script's own size, with no cumulative limit.

Add the allowance noteScriptTarget already has. A target past it stays
unassessed and counts as a skipped map, and is never read.
This commit is contained in:
Matt Van Horn committed 2026-11-02 02:37:59 -08:00
1 parent fa91268045
commit 52c801e7dc
3 files changed
+51 -2

No files matched your search

@@ -34,6 +34,7 @@ import {
MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES,
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES,
MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES,
MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES,
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES,
MAX_RELEASE_EXPOSURE_SOURCE_MAPS,
@@ -2733,6 +2734,37 @@ describe("ReleaseExposureReader inline map reads", () => {
});
});
describe("ReleaseExposureReader map target allowance", () => {
it("keeps a map past the target allowance unassessed", async () => {
const root = await createTestTempDirectory("rea-release-exposure-maps-");
const folder = join(
root,
...Array.from({ length: 4 }, () => "d".repeat(200)),
);
await mkdir(folder, { recursive: true });
const declarations = Array.from(
{ length: Math.ceil(MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES / 800) + 1 },
(_, index) => `//# sourceMappingURL=m${String(index)}`,
);
await writeFile(
join(folder, "a.js"),
`${declarations.join("\n")}\n//# sourceMappingURL=z\n`,
);
await writeFile(
join(folder, "z"),
JSON.stringify({
version: 3,
sources: ["s.ts"],
sourcesContent: ["x"],
mappings: "",
}),
);
const scanned = await inspect(root);
expect(scanned.source_maps.embedded).toBe(0);
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
});
});
describe("ReleaseExposureReader HTML script references", () => {
const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(
JSON.stringify({
+16 -2
View File
@@ -78,6 +78,7 @@ import {
MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES,
MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS,
MAX_RELEASE_EXPOSURE_SAMPLES,
MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES,
MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES,
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES,
MAX_RELEASE_EXPOSURE_SOURCE_MAPS,
@@ -753,6 +754,7 @@ interface ScanState {
scriptTargets: Set<string>;
/** Bytes of resolved path offered to `scriptTargets`, kept or refused. */
scriptTargetBytes: number;
mapTargetBytes: number;
/** Members already examined as a script. */
examinedScripts: Set<string>;
visitText: ReleaseExposureTextVisitor;
@@ -795,6 +797,7 @@ const createState = (visitText: ReleaseExposureTextVisitor): ScanState => ({
examinedMaps: new Set(),
scriptTargets: new Set(),
scriptTargetBytes: 0,
mapTargetBytes: 0,
examinedScripts: new Set(),
visitText,
});
@@ -1362,10 +1365,21 @@ const noteLocalMapReference = (
noteMapTarget(state, resolve(dirname(member.location), decoded));
};
/**
* A short URL is expanded by the declaring member's directory, so declared
* paths can outgrow their text many times over. A target past the allowance
* stays unassessed.
*/
const noteMapTarget = (state: ScanState, location: string): void => {
const target = memberLocationKey(location);
if (state.examinedMaps.has(target)) return;
if (state.examinedLocations.has(target)) state.skippedMaps += 1;
if (state.examinedMaps.has(target) || state.mapTargets.has(target)) return;
if (state.examinedLocations.has(target)) {
state.skippedMaps += 1;
return;
}
state.mapTargetBytes += Buffer.byteLength(target);
if (state.mapTargetBytes > MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES)
state.skippedMaps += 1;
else state.mapTargets.add(target);
};
+3
View File
@@ -19,6 +19,8 @@ export const MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES = 256 * 1024;
export const MAX_RELEASE_EXPOSURE_TEXT_BYTES = 4 * 1024 * 1024;
/** Resolved paths retained for HTML script targets across one audit. */
export const MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES = 1024 * 1024;
/** Resolved paths retained for local source map targets across one audit. */
export const MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES = 1024 * 1024;
/** Source maps read from one artifact. */
export const MAX_RELEASE_EXPOSURE_SOURCE_MAPS = 32;
/** Bytes read from one source map. */
@@ -43,6 +45,7 @@ export const RELEASE_EXPOSURE_ALLOWANCES = [
`Selected file reads stop at ${String(MAX_RELEASE_EXPOSURE_FILE_BYTES)} bytes.`,
`Text member scans read at most ${String(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES)} bytes each and ${String(MAX_RELEASE_EXPOSURE_TEXT_BYTES)} bytes in total.`,
`HTML script targets keep at most ${String(MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES)} bytes of resolved paths. Later targets stay unassessed.`,
`Local source map targets keep at most ${String(MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES)} bytes of resolved paths. Later targets stay unassessed.`,
`Source map scans read at most ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAPS)} maps and ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES)} bytes each. sourcesContent text is counted and then dropped.`,
`Other files and package members are identified from their first ${String(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES)} bytes, and those without a NUL byte in that prefix are scanned as text. Up to ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS)} packaged PE images are read for debug records, ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES)} bytes each.`,
`Go, Rust, and Electron fuse readers run on at most ${String(MAX_RELEASE_EXPOSURE_NATIVE_IMAGES)} native images.`,