diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 144b0ae4a6..65a7286f97 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -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({ diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 308fc53772..095fa2f080 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -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; /** Bytes of resolved path offered to `scriptTargets`, kept or refused. */ scriptTargetBytes: number; + mapTargetBytes: number; /** Members already examined as a script. */ examinedScripts: Set; 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); }; diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index f34aef1ce7..7d5ec77193 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -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.`,