diff --git a/docs/release-exposure.md b/docs/release-exposure.md index 9351275e4e..fe8471b5c2 100644 --- a/docs/release-exposure.md +++ b/docs/release-exposure.md @@ -48,7 +48,7 @@ The audit composes existing readers. It does not reread Electron fuse bytes itse `native.hardening`, `macho.hardened_runtime`, and `android.manifest` stay `not_assessed`. Their next probes are `inspect_binary_hardening`, `inspect_signature`, and `inspect_android_package`. -Source map text is counted and then dropped. A selected file is read through the stable artifact reader. Text and source-map members stay inside the allowances listed under `coverage.allowances`, and each kind has its own allowance, so binary members cannot use up the read that a later source map needs. Native images on disk are registered from inventory classification and are not limited to those allowances. Other files and package members are identified from a bounded header. The bytes a package member's text or map read returns decide before its name does, so a PE called `payload.js.map` is read as a PE. A header with no NUL byte, or one that starts with a UTF-16 byte order mark, is scanned as text, so names such as `.env.production`, `server.key`, and `credentials` do not depend on an extension list. UTF-16 without a byte order mark has NUL bytes and is not recognized this way. A member already known to be text, such as a script, is also scanned when it is UTF-8 holding a stray NUL. One whose header is at least an eighth NUL bytes is taken to be wide text. It is not decoded and is counted as skipped, as is UTF-32. A binary plist is decoded, so its strings, dictionary keys and data are scanned in whichever encoding each object uses. One that cannot be decoded still has its bytes scanned and is counted as skipped. A file whose header could not be read leaves `text.credential_markers` undecided. A packaged ELF, Mach-O, or PE image is read in full and written to a temporary copy, removed afterward, so it can be probed for an embedded package. All packaged images share one `max_file_bytes` allowance. An image that does not fit, or whose bytes cannot be read, is not probed and leaves member coverage `unknown`. A packaged PE is also read for its debug records. An SVG file is read for its scripts as an HTML file is. A script that an HTML file loads is examined as a script whatever it is called, and an HTML file loaded this way is still examined as HTML. One read before that HTML file, one that is not found, one at a location the audit cannot place, or one past the 1 MiB of resolved script paths the audit keeps (`MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES`) leaves `javascript.source_map_sources` undecided. Skipped members are listed under `coverage.omitted`. When the selection is a file, the parent directory is listed up to a fixed entry allowance for sibling `.pdb`, `.dSYM`, `.debug`, and linker `.map` debug companions. A sibling source map that the selected file declares, such as `app.js.map`, or a sibling script that a selected HTML file loads, such as `app.js`, is not read as part of the artifact, so it leaves `javascript.source_map_sources` undecided. +Source map text is counted and then dropped. A selected file is read through the stable artifact reader. Text and source-map members stay inside the allowances listed under `coverage.allowances`, and each kind has its own allowance, so binary members cannot use up the read that a later source map needs. Native images on disk are registered from inventory classification and are not limited to those allowances. Other files and package members are identified from a bounded header. The bytes a package member's text or map read returns decide before its name does, so a PE called `payload.js.map` is read as a PE. A header with no NUL byte, or one that starts with a UTF-16 byte order mark, is scanned as text, so names such as `.env.production`, `server.key`, and `credentials` do not depend on an extension list. UTF-16 without a byte order mark has NUL bytes and is not recognized this way. A member already known to be text, such as a script, is also scanned when it is UTF-8 holding a stray NUL. One whose header is at least an eighth NUL bytes is taken to be wide text. It is not decoded and is counted as skipped, as is UTF-32. A binary plist is decoded, so its strings, dictionary keys and data are scanned in whichever encoding each object uses. One that cannot be decoded still has its bytes scanned and is counted as skipped. A file whose header could not be read leaves `text.credential_markers` undecided. A packaged ELF, Mach-O, or PE image is read in full and written to a temporary copy, removed afterward, so it can be probed for an embedded package. All packaged images share one `max_file_bytes` allowance. An image that does not fit, or whose bytes cannot be read, is not probed and leaves member coverage `unknown`. A packaged PE is also read for its debug records. An SVG file is read for its scripts as an HTML file is. A script that an HTML file loads, or a Node SEA main script, is examined as a script whatever it is called, so a name such as `boot.map` does not make it a map, and an HTML file loaded this way is still examined as HTML. One read before that HTML file, one that is not found, one at a location the audit cannot place, or one past the 1 MiB of resolved script paths the audit keeps (`MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES`) leaves `javascript.source_map_sources` undecided. Skipped members are listed under `coverage.omitted`. When the selection is a file, the parent directory is listed up to a fixed entry allowance for sibling `.pdb`, `.dSYM`, `.debug`, and linker `.map` debug companions. A sibling source map that the selected file declares, such as `app.js.map`, or a sibling script that a selected HTML file loads, such as `app.js`, is not read as part of the artifact, so it leaves `javascript.source_map_sources` undecided. Inventory stops at 65,536 entries or 2 GiB of hashed bytes, and member coverage then stays `unknown`. A packed ASAR member is read from the captured archive, whose digest must match inventory. An unpacked member is read from its `.unpacked` companion, so its full bytes must also match the digest inventory recorded for that member, and a companion replaced after inventory fails the audit as a changed artifact. A declared integrity value that disagrees with the bytes does not stop the audit, because signing an unpacked native module after packaging is routine. The contradiction is recorded in the limitations and the audit continues. diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 002369289d..965c7c866c 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -3287,6 +3287,20 @@ describe("ReleaseExposureReader inline map reads", () => { ); expect((await inspect(root)).source_maps.embedded).toBe(1); }); + + it("reads a SEA entry named .map as JavaScript, not as a linker map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-map-"); + await writeFile( + join(root, "app"), + nodeSeaElf({ + codePath: "boot.map", + main: `//# sourceMappingURL=data:application/json;base64,${map}\n`, + }), + ); + const scanned = await inspect(root); + expect(scanned.source_maps.embedded).toBe(1); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); }); describe("ReleaseExposureReader Node shebang scripts", () => { @@ -3495,6 +3509,32 @@ describe("ReleaseExposureReader map target allowance", () => { }); }); +describe("ReleaseExposureReader script roles over a .map name", () => { + it("reads a .map script an HTML file loads as JavaScript", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + const map = Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64"); + await writeFile( + join(root, "index.html"), + '', + ); + await writeFile( + join(root, "main.map"), + `//# sourceMappingURL=data:application/json;base64,${map}\n`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); +}); + 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 7258a6f691..9c1b40c743 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -1649,9 +1649,19 @@ const noteLinkerMapGuess = (state: ScanState, location: string): void => { state.linkerMapGuesses.set(key, paths.add(location)); }; -const isDeclaredMap = (state: ScanState, member: MemberView): boolean => - isSourceMap(member.logical, member.kind) || - state.mapTargets.has(memberLocationKey(member.location)); +/** + * An owner's stated role or a declaration outranks the member's name, and an + * inventory path role is only that name: a `.map` that an owner marks as + * JavaScript or a page loads as a script is not a map. + */ +const isDeclaredMap = (state: ScanState, member: MemberView): boolean => { + if (member.kindStated && member.kind === "source-map") return true; + const key = memberLocationKey(member.location); + if (state.mapTargets.has(key)) return true; + if (member.kind === "javascript" || state.scriptTargets.has(key)) + return false; + return isSourceMap(member.logical, member.kind); +}; /** A declared map or script target that was never reached was not assessed. */ const noteUnreachedTargets = (state: ScanState): void => {