diff --git a/docs/release-exposure.md b/docs/release-exposure.md
index 7990b5e944..450b598c0d 100644
--- a/docs/release-exposure.md
+++ b/docs/release-exposure.md
@@ -58,7 +58,7 @@ A `.map` too large to parse is a linker map when its header cannot begin a JSON
## Status
-`exposed` means the check's condition was observed. `not_observed` means that condition was absent in a completed scan. `not_applicable` means the check does not apply to this artifact, for example Electron fuses on an app bundle with no Electron Framework, or on a directory whose native images were all read and hold no fuse sentinel. `unknown` means the check could not be decided, including a directory with no ASAR layout where those reads are missing or incomplete (an `asar: false` Electron build has no ASAR to find), safe fuses from an incomplete inventory or an unopened packaged native image that has not accounted for the Electron executable, a missing fuse sentinel on an Electron target, a universal binary with a slice that has no sentinel or an undecided fuse, a file that changed after inventory, Electron fuses on a selected ASAR (which does not include the executable that holds them), a PE inside a package that was too large to read, Go or Rust metadata on a native image stored inside a package, and Go or Rust metadata that was not present. Absence of that metadata is not proof the binary is something else. `not_assessed` means this audit did not run the check.
+`exposed` means the check's condition was observed. `not_observed` means that condition was absent in a completed scan. `not_applicable` means the check does not apply to this artifact, for example Electron fuses on an app bundle with no Electron Framework, or on a directory whose native images were all read and hold no fuse sentinel. `unknown` means the check could not be decided, including a directory with no ASAR layout where those reads are missing or incomplete (an `asar: false` Electron build has no ASAR to find), safe fuses from an incomplete inventory, an unopened packaged native image, or a dropped image when the sample did not read an executable named as the Electron host or every executable that could be it, a missing fuse sentinel on an Electron target, a universal binary with a slice that has no sentinel or an undecided fuse, a file that changed after inventory, Electron fuses on a selected ASAR (which does not include the executable that holds them), a PE inside a package that was too large to read, Go or Rust metadata on a native image stored inside a package, and Go or Rust metadata that was not present. Absence of that metadata is not proof the binary is something else. `not_assessed` means this audit did not run the check.
Cancellation during a Go, Rust, fuse, or desktop read returns a typed cancellation error. Its partial observation holds the report with completed facts, and checks whose readers did not run are `unknown`. Cancellation during the member scan itself returns no partial report.
diff --git a/src/application/releaseExposure/ReleaseExposureService.test.ts b/src/application/releaseExposure/ReleaseExposureService.test.ts
index e061fa3bae..b2ec1b0c12 100644
--- a/src/application/releaseExposure/ReleaseExposureService.test.ts
+++ b/src/application/releaseExposure/ReleaseExposureService.test.ts
@@ -894,6 +894,66 @@ describe("ReleaseExposureService packaged native images", () => {
});
});
+describe("ReleaseExposureService Electron host identity", () => {
+ it("keeps safe fuses decided when inventory stopped after the named executable", async () => {
+ const { service, desktop, electronFuses } = owners({
+ ...directoryScan(["/work/app/app.exe"]),
+ coverage: "unknown",
+ });
+ desktop.inspect.mockResolvedValue(ok(evidence(electronDesktopReport())));
+ electronFuses.inspect.mockResolvedValue(
+ ok(evidence(fuseReport([slice(0)]))),
+ );
+ expect((await runAsNode(service))?.status).toBe("not_observed");
+ });
+
+ it("keeps safe fuses unknown when inventory stopped after an unnamed sibling", async () => {
+ const { service, desktop, electronFuses } = owners({
+ ...directoryScan(["/work/app/helper.exe"]),
+ coverage: "unknown",
+ });
+ desktop.inspect.mockResolvedValue(ok(evidence(electronDesktopReport())));
+ electronFuses.inspect.mockResolvedValue(
+ ok(evidence(fuseReport([slice(0)]))),
+ );
+ expect((await runAsNode(service))?.status).toBe("unknown");
+ });
+
+ it("keeps safe fuses unknown when the sample dropped an executable that could be the host", async () => {
+ const { service, desktop, electronFuses } = owners(
+ directoryScan(
+ ["one", "two", "three", "four", "five"].map(
+ (name) => `/work/app/${name}.exe`,
+ ),
+ ),
+ );
+ desktop.inspect.mockResolvedValue(ok(evidence(electronDesktopReport())));
+ electronFuses.inspect.mockResolvedValue(
+ ok(evidence(fuseReport([slice(0)]))),
+ );
+ expect((await runAsNode(service))?.status).toBe("unknown");
+ expect(electronFuses.inspect).toHaveBeenCalledTimes(4);
+ });
+
+ it("keeps safe fuses decided when the sample kept the named host among many siblings", async () => {
+ const { service, desktop, electronFuses } = owners(
+ directoryScan(
+ ["one", "two", "three", "four", "app"].map(
+ (name) => `/work/app/${name}.exe`,
+ ),
+ ),
+ );
+ desktop.inspect.mockResolvedValue(ok(evidence(electronDesktopReport())));
+ electronFuses.inspect.mockResolvedValue(
+ ok(evidence(fuseReport([slice(0)]))),
+ );
+ expect((await runAsNode(service))?.status).toBe("not_observed");
+ expect(electronFuses.inspect.mock.calls[0]?.[0]).toMatchObject({
+ path: "/work/app/app.exe",
+ });
+ });
+});
+
describe("ReleaseExposureService Electron applicability", () => {
const appScan = scan({
target: {
@@ -939,18 +999,6 @@ describe("ReleaseExposureService Electron applicability", () => {
expect((await runAsNode(service))?.status).toBe("unknown");
});
- it("keeps safe fuses decided when inventory stopped after the hinted executable", async () => {
- const { service, desktop, electronFuses } = owners({
- ...directoryScan(["/work/app/MyApp.exe"]),
- coverage: "unknown",
- });
- desktop.inspect.mockResolvedValue(ok(evidence(electronDesktopReport())));
- electronFuses.inspect.mockResolvedValue(
- ok(evidence(fuseReport([slice(0)]))),
- );
- expect((await runAsNode(service))?.status).toBe("not_observed");
- });
-
it("keeps safe fuses decided on a selected executable with unknown inventory", async () => {
const { service, electronFuses } = owners(
scan({
diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts
index 9724dd21e4..d857bcf1d7 100644
--- a/src/application/releaseExposure/ReleaseExposureService.ts
+++ b/src/application/releaseExposure/ReleaseExposureService.ts
@@ -679,9 +679,14 @@ const limitNativeSample = (
scan.fuse_candidates.some((candidate) => candidate.role === "app-bundle") ||
kept.some(
(image) =>
- preferredPaths.has(image.path) ||
- image.path === scan.target.selected_path,
- );
+ image.path === scan.target.selected_path ||
+ (ranked.get(image.path) ?? ANY_SIBLING_RANK) < ANY_SIBLING_RANK,
+ ) ||
+ // No name picks the host out, so it counts as read only when every
+ // executable it could be was listed and sampled.
+ (scan.coverage !== "unknown" &&
+ preferred.length > 0 &&
+ preferred.length <= MAX_RELEASE_EXPOSURE_NATIVE_IMAGES);
return {
electronExecutableIncluded,
scan: {
@@ -710,6 +715,12 @@ const limitNativeSample = (
};
};
+/**
+ * A lower rank names the Electron host. This one only orders the sample: any
+ * executable beside the archive could be a helper.
+ */
+const ANY_SIBLING_RANK = 2;
+
const electronExecutableRank = (
images: readonly ReleaseExposureNativeImage[],
basisPaths: readonly string[],
@@ -732,8 +743,9 @@ const electronExecutableRank = (
let score = 3;
if (lower === folderName || lower === `${folderName}.exe`) score = 0;
else if (lower === "electron" || lower === "electron.exe") score = 1;
- else if (lower.endsWith(".exe") || !name.includes(".")) score = 2;
- if (score <= 2) remember(image.path, score);
+ else if (lower.endsWith(".exe") || !name.includes("."))
+ score = ANY_SIBLING_RANK;
+ if (score <= ANY_SIBLING_RANK) remember(image.path, score);
}
}
return ranked;
diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts
index 5ced548d38..16bfe2ec8f 100644
--- a/src/artifacts/ReleaseExposureReader.test.ts
+++ b/src/artifacts/ReleaseExposureReader.test.ts
@@ -10,6 +10,11 @@ import { describe, expect, it, vi } from "vitest";
import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js";
import { pe } from "../domain/binaryTarget.fixture.js";
import { nodeSeaElf } from "./nodeSea/NodeSea.fixture.js";
+import {
+ bunModuleGraphPayload,
+ bunPrefixedPayload,
+ elfBunSection,
+} from "./bun/BunStandalone.fixture.js";
import { dotnetBundleFixture } from "./dotnet/DotnetBundle.fixture.js";
import { cArchiveExecutable } from "./pyinstaller/CArchive.fixture.js";
import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
@@ -32,6 +37,7 @@ import {
PACKAGED_NATIVE_IMAGES,
type ReleaseExposureScan,
} from "../domain/releaseExposure.js";
+import { HTML_INLINE_SCRIPT_LOCATION_LIMIT } from "../domain/javascript/htmlArtifactReferences.js";
import { ReleaseExposureReader } from "./ReleaseExposureReader.js";
const stableRead = vi.hoisted(() => ({
@@ -2215,6 +2221,109 @@ describe("ReleaseExposureReader inline map reads", () => {
}),
).toString("base64");
+ const dataUrl = `data:application/json;base64,${map}`;
+
+ it.each([
+ ["a classic script", "`,
+ );
+ const scanned = await inspect(root);
+ expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
+ expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
+ });
+
+ it("reads an inline map in an HTML member of an ASAR", async () => {
+ const root = await createTestTempDirectory("rea-release-exposure-html-");
+ const path = join(root, "app.asar");
+ await writeFile(
+ path,
+ asarArchive([
+ {
+ name: "index.html",
+ bytes: Buffer.from(
+ ``,
+ ),
+ },
+ ]),
+ );
+ expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed");
+ });
+
+ it("does not read a map in a data block or a script that loads by URL", async () => {
+ const root = await createTestTempDirectory("rea-release-exposure-html-");
+ await writeFile(
+ join(root, "index.html"),
+ `` +
+ ``,
+ );
+ const scanned = await inspect(root);
+ expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 });
+ expect(sourceMapSourcesStatus(scanned)).toBe("not_observed");
+ });
+
+ it("keeps HTML map coverage unknown for scripts it did not read", async () => {
+ const root = await createTestTempDirectory("rea-release-exposure-html-");
+ const path = join(root, "index.html");
+ await writeFile(
+ path,
+ "".repeat(HTML_INLINE_SCRIPT_LOCATION_LIMIT + 2),
+ );
+ const past = await inspect(path);
+ expect(past.source_maps.skipped).toBe(2);
+ expect(sourceMapSourcesStatus(past)).toBe("unknown");
+ await writeFile(
+ path,
+ Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
+ );
+ expect((await inspect(path)).source_maps.skipped).toBe(1);
+ });
+
+ const bunExecutable = (sourcemap: Buffer): Buffer =>
+ elfBunSection(
+ bunPrefixedPayload(
+ bunModuleGraphPayload({
+ modules: [
+ {
+ name: "/$bunfs/root/main.js",
+ contents: Buffer.from("run();\n"),
+ sourcemap,
+ },
+ ],
+ }),
+ ),
+ );
+
+ it.each([
+ ["a selected executable", (root: string) => join(root, "server")],
+ ["an executable in a directory", (root: string) => root],
+ ])("reads a Bun sourcemap region in %s", async (_, selected) => {
+ const root = await createTestTempDirectory("rea-release-exposure-bun-");
+ await writeFile(
+ join(root, "server"),
+ bunExecutable(Buffer.from(map, "base64")),
+ );
+ const scanned = await inspect(selected(root));
+ expect(scanned.source_maps).toMatchObject({ embedded: 1, unparsed: 0 });
+ expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
+ });
+
+ it("keeps a binary Bun sourcemap region unresolved instead of a companion", async () => {
+ const root = await createTestTempDirectory("rea-release-exposure-bun-");
+ await writeFile(
+ join(root, "server"),
+ bunExecutable(Buffer.from([1, 0, 0, 0, 0xff, 0xfe, 0, 0])),
+ );
+ const scanned = await inspect(join(root, "server"));
+ expect(scanned.source_maps.unparsed).toBe(1);
+ expect(scanned.debug_companions.paths).toEqual([]);
+ expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
+ });
+
it("reads a percent-escaped base64 inline map", async () => {
const root = await createTestTempDirectory("rea-release-exposure-map-");
const escaped = `%${map.charCodeAt(0).toString(16)}${map.slice(1)}`;
diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts
index 524ef9fcdc..37bf99c4cb 100644
--- a/src/artifacts/ReleaseExposureReader.ts
+++ b/src/artifacts/ReleaseExposureReader.ts
@@ -101,6 +101,7 @@ import {
inspectSourceMapValue,
SourceMapFormatFailure,
} from "../javascript/sourceMaps/SourceMapFormat.js";
+import { htmlArtifactReferences } from "../domain/javascript/htmlArtifactReferences.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { addSourceMapDirectives } from "../domain/javascript/javascriptStaticAnalysisCalls.js";
import { createJavaScriptAnalysisAccumulator } from "../domain/javascript/javascriptStaticAnalysisState.js";
@@ -137,6 +138,7 @@ const MEMBER_ENCODING_HEADER_BYTES = 512;
const WIDE_TEXT_NUL_DIVISOR = 8;
const PE_MEMBER_NAME = /\.(?:dll|exe|sys)$/iu;
const JAVASCRIPT_MEMBER_NAME = /\.(?:[cm]?[jt]s|[jt]sx)$/iu;
+const HTML_MEMBER_NAME = /\.html?$/iu;
type NativeFormat = "elf" | "mach-o" | "mach-o-universal" | "pe";
type InventoryOccurrence = ArtifactInventoryResult["occurrences"][number];
@@ -514,7 +516,10 @@ const scanOccurrences = async (
await examineMember(state, {
logical,
format: nativeFormat ?? format,
- kind: occurrence.artifact_kind,
+ kind: ownerMemberKind(
+ occurrence.format_metadata,
+ occurrence.artifact_kind,
+ ),
encrypted: occurrence.encrypted,
location,
packageFormat:
@@ -604,7 +609,7 @@ const scanOccurrences = async (
subject: "source maps",
count: state.skippedMaps,
reason:
- "A source map exceeded the map count or byte allowance, could not be read, was past the package entry cap, or was declared but not found; inline CSS source-map directives are not inspected.",
+ "A source map exceeded the map count or byte allowance, could not be read, was past the package entry cap or the HTML inline script limit, or was declared but not found; inline CSS source-map directives are not inspected.",
},
]
: []),
@@ -749,6 +754,25 @@ const isJavaScriptMember = (member: MemberView): boolean =>
const isCssMember = (member: MemberView): boolean =>
member.logical.toLowerCase().endsWith(".css");
+const isHtmlMember = (member: MemberView): boolean =>
+ HTML_MEMBER_NAME.test(member.logical);
+
+/** A member whose scripts can declare a map the directive parser reads. */
+const holdsScripts = (member: MemberView): boolean =>
+ isJavaScriptMember(member) || isHtmlMember(member);
+
+/**
+ * The role a package reader states for its entry. A Bun sourcemap region is
+ * named `.bunmap`, so its name alone does not say it is a source map.
+ */
+const ownerMemberKind = (
+ metadata: ArtifactOccurrence["format_metadata"],
+ kind: string,
+): string =>
+ metadata?.kind === "bun-standalone-entry" && metadata.region === "sourcemap"
+ ? "source-map"
+ : kind;
+
const asNativeFormat = (
format: string | null | undefined,
): NativeFormat | null =>
@@ -849,7 +873,7 @@ const examineMember = async (
};
const noteUnreadMember = (state: ScanState, member: MemberView): void => {
- if (isJavaScriptMember(member) || isCssMember(member)) state.skippedMaps += 1;
+ if (holdsScripts(member) || isCssMember(member)) state.skippedMaps += 1;
if (isDeclaredMap(state, member)) {
noteUnreadMap(state, member, null);
state.skippedText += 1;
@@ -874,8 +898,9 @@ const examineMapMember = async (
state: ScanState,
member: MemberView,
): Promise => {
- // A member a script names as its map is a JavaScript map whatever it is called.
- const declared = isSourceMap(member.logical, member.kind)
+ // A member a script names or an owner marks as a map is a JavaScript map
+ // whatever it is called.
+ const declared = member.logical.toLowerCase().endsWith(".map")
? member
: { ...member, logical: `${member.logical}.js.map` };
const loaded = await examineMap(state, declared);
@@ -1048,7 +1073,7 @@ const examineTextMember = async (
const remaining = MAX_RELEASE_EXPOSURE_TEXT_BYTES - state.textBytes;
if (remaining <= 0) {
state.skippedText += 1;
- if (isJavaScriptMember(member)) state.skippedMaps += 1;
+ if (holdsScripts(member)) state.skippedMaps += 1;
return true;
}
const loaded = await member.read(
@@ -1057,13 +1082,15 @@ const examineTextMember = async (
if (loaded.kind === "bytes") {
const text = decodeMemberText(loaded.bytes);
scanText(state, member, loaded.bytes.length, text);
- if (isJavaScriptMember(member)) {
+ if (holdsScripts(member)) {
if (text === null) state.skippedMaps += 1;
- else examineInlineMaps(state, member, text.text);
+ else if (isJavaScriptMember(member))
+ examineInlineMaps(state, member, text.text, member.logical);
+ else examineHtmlScripts(state, member, text.text);
}
} else {
state.skippedText += 1;
- if (isJavaScriptMember(member)) state.skippedMaps += 1;
+ if (holdsScripts(member)) state.skippedMaps += 1;
}
return loaded.kind !== "unread";
};
@@ -1092,12 +1119,33 @@ const examineProbedText = async (
await examineTextMember(state, member);
};
-const examineInlineMaps = (
+/**
+ * A script element's body declares maps as a script file does. Bodies past the
+ * HTML owner's retained limit were not read, so each counts as a skipped map.
+ */
+const examineHtmlScripts = (
state: ScanState,
member: MemberView,
source: string,
): void => {
- const parsed = parseJavaScriptSource(source, member.logical);
+ const { inlineScripts, inlineScriptCount } = htmlArtifactReferences(source);
+ for (const script of inlineScripts)
+ examineInlineMaps(
+ state,
+ member,
+ script.text,
+ script.scriptType === "module" ? "inline.mjs" : "inline.js",
+ );
+ state.skippedMaps += inlineScriptCount - inlineScripts.length;
+};
+
+const examineInlineMaps = (
+ state: ScanState,
+ member: MemberView,
+ source: string,
+ sourcePath: string,
+): void => {
+ const parsed = parseJavaScriptSource(source, sourcePath);
if (parsed === null) {
state.skippedMaps += 1;
return;
@@ -1612,7 +1660,9 @@ const scanUnexpandedContainer = async (
await examineMember(state, {
logical: entry.path,
format: seaMainScript ? "javascript-bundle" : "file",
- kind: seaMainScript ? "javascript" : "file",
+ kind: seaMainScript
+ ? "javascript"
+ : ownerMemberKind(entry.formatMetadata, "file"),
encrypted: entry.encrypted,
location,
packageFormat: format,
diff --git a/src/domain/javascript/htmlArtifactReferences.test.ts b/src/domain/javascript/htmlArtifactReferences.test.ts
index 7dbf2f363f..1a3d525288 100644
--- a/src/domain/javascript/htmlArtifactReferences.test.ts
+++ b/src/domain/javascript/htmlArtifactReferences.test.ts
@@ -123,6 +123,7 @@ it("retains exact inline script body coordinates, including unclosed scripts", (
{
scriptType: "classic",
location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
+ text: body,
},
]);
const unclosed = `${prefix}${body}`;
@@ -130,6 +131,7 @@ it("retains exact inline script body coordinates, including unclosed scripts", (
{
scriptType: "classic",
location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
+ text: body,
},
]);
});
@@ -147,6 +149,7 @@ it("counts every inline body but retains bounded locations", () => {
start: { line: 1, column },
end: { line: 1, column: column + 1 },
},
+ text: "x",
});
});
diff --git a/src/domain/javascript/htmlArtifactReferences.ts b/src/domain/javascript/htmlArtifactReferences.ts
index 0edb22fb69..eed30cf3f6 100644
--- a/src/domain/javascript/htmlArtifactReferences.ts
+++ b/src/domain/javascript/htmlArtifactReferences.ts
@@ -22,6 +22,8 @@ export interface HtmlInlineScript {
readonly scriptType: "classic" | "module";
/** The body's range, between the opening and closing tags. */
readonly location: JavaScriptSourceRange;
+ /** The body as the HTML parser decoded it. */
+ readonly text: string;
}
/**
@@ -129,7 +131,7 @@ export const htmlArtifactReferences = (
if (inline !== undefined && scriptType !== null) {
inlineScriptCount += 1;
if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT)
- inlineScripts.push({ scriptType, location: inline });
+ inlineScripts.push({ scriptType, ...inline });
}
}
}
@@ -145,22 +147,27 @@ export const htmlArtifactReferences = (
/** Locate a script element's non-blank body; browsers ignore it when src is set. */
const inlineScriptBody = (
node: DefaultTreeAdapterTypes.Element,
-): JavaScriptSourceRange | undefined => {
+): Pick | undefined => {
const startTag = node.sourceCodeLocation?.startTag;
if (startTag === undefined) return undefined;
let blank = true;
+ let text = "";
let end: DefaultTreeAdapterTypes.TextNode["sourceCodeLocation"];
for (const child of node.childNodes) {
if (!defaultTreeAdapter.isTextNode(child)) continue;
if (blank && /\S/u.test(child.value)) blank = false;
+ text += child.value;
end = child.sourceCodeLocation ?? end;
}
// An unclosed script's element range ends at its opening tag, so the body's
// own text location bounds it.
if (blank || end == null) return undefined;
return {
- start: sourcePoint(startTag.endLine, startTag.endCol),
- end: sourcePoint(end.endLine, end.endCol),
+ location: {
+ start: sourcePoint(startTag.endLine, startTag.endCol),
+ end: sourcePoint(end.endLine, end.endCol),
+ },
+ text,
};
};