fix(audit): let stated JavaScript roles outrank a .map name

isDeclaredMap() routed any member whose name ended in .map to map
handling before the owner's stated role or a page's script declaration
was considered. A Node SEA main script at boot.map, which the SEA reader
marks as javascript, and a .map script an HTML file loads both became
linker-map guesses, so their inline maps were never read and
native.debug_companions was reported exposed.

An owner-stated source-map role or a declared map target still makes a
member a map. Otherwise a stated javascript kind or a script target now
wins, and only then does the .map suffix, or the inventory path role that
repeats it, decide. Regression tests cover both cases, and the guide
notes that a SEA main script is read as a script whatever it is called.
This commit is contained in:
Matt Van Horn committed 2026-11-07 17:10:23 -08:00
1 parent ea87cf8984
commit 1d3f89f155
3 files changed
+54 -4

No files matched your search

+1 -1
View File
@@ -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`. `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. 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.
@@ -3287,6 +3287,20 @@ describe("ReleaseExposureReader inline map reads", () => {
); );
expect((await inspect(root)).source_maps.embedded).toBe(1); 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", () => { 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"),
'<script src="main.map"></script>',
);
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", () => { describe("ReleaseExposureReader HTML script references", () => {
const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from( const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(
JSON.stringify({ JSON.stringify({
+13 -3
View File
@@ -1649,9 +1649,19 @@ const noteLinkerMapGuess = (state: ScanState, location: string): void => {
state.linkerMapGuesses.set(key, paths.add(location)); state.linkerMapGuesses.set(key, paths.add(location));
}; };
const isDeclaredMap = (state: ScanState, member: MemberView): boolean => /**
isSourceMap(member.logical, member.kind) || * An owner's stated role or a declaration outranks the member's name, and an
state.mapTargets.has(memberLocationKey(member.location)); * 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. */ /** A declared map or script target that was never reached was not assessed. */
const noteUnreachedTargets = (state: ScanState): void => { const noteUnreachedTargets = (state: ScanState): void => {