diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts index bfc2c4e231..d6fd7f2673 100644 --- a/src/application/releaseExposure/ReleaseExposureService.ts +++ b/src/application/releaseExposure/ReleaseExposureService.ts @@ -242,6 +242,7 @@ export class ReleaseExposureService { electron_family: family.family, electron_family_reason: family.reason, electron_executable_included: prepared.electronExecutableIncluded, + electron_host_paths: prepared.electronHostPaths, fuse_images: fuseImages, go_images: goImages, rust_images: rustImages, @@ -671,6 +672,7 @@ const limitNativeSample = ( ): { readonly scan: ReleaseExposureScan; readonly electronExecutableIncluded: boolean; + readonly electronHostPaths: readonly string[]; } => { const ranked = electronExecutableRank(scan.native_images, basisPaths); const preferred = scan.native_images @@ -700,6 +702,11 @@ const limitNativeSample = ( preferred.length <= MAX_RELEASE_EXPOSURE_NATIVE_IMAGES); return { electronExecutableIncluded, + electronHostPaths: kept.flatMap((image) => + (ranked.get(image.path) ?? ANY_SIBLING_RANK) < ANY_SIBLING_RANK + ? [image.path] + : [], + ), scan: { ...scan, native_images: kept, diff --git a/src/domain/releaseExposure.test.ts b/src/domain/releaseExposure.test.ts index 21a898bd98..bb3dff27a4 100644 --- a/src/domain/releaseExposure.test.ts +++ b/src/domain/releaseExposure.test.ts @@ -710,3 +710,46 @@ describe("go.build_paths locations", () => { ]); }); }); + +describe("electron host fuses beside other images", () => { + const safeImage = { + kind: "report" as const, + role: "native-image" as const, + path: "/work/app/helper", + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + { subject: "node_cli_inspect_arguments", outcome: "not_honored" }, + { subject: "embedded_asar_integrity_validation", outcome: "enforced" }, + { subject: "only_load_app_from_asar", outcome: "restricted_to_app_asar" }, + ], + }; + const hostWithoutFuses = { + kind: "report" as const, + role: "native-image" as const, + path: "/work/app/app", + implications: [], + }; + const report = (electron_host_paths?: readonly string[]) => + reportFor( + scan(), + owners({ + electron_family: "electron", + electron_executable_included: true, + ...(electron_host_paths === undefined ? {} : { electron_host_paths }), + fuse_images: [hostWithoutFuses, safeImage], + }), + ); + + it("leaves an unresolved host unknown whatever another image reports", () => { + expect(statusOf(report(["/work/app/app"]), "electron.run_as_node")).toBe( + "unknown", + ); + }); + + it("does not let a fuseless helper unsettle the host's safe answer", () => { + expect(statusOf(report(["/work/app/helper"]), "electron.run_as_node")).toBe( + "not_observed", + ); + expect(statusOf(report(), "electron.run_as_node")).toBe("not_observed"); + }); +}); diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index 53141cf087..f34aef1ce7 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -421,6 +421,8 @@ export interface ReleaseExposureOwnerFacts { | "not_a_directory"; readonly electron_family_reason?: string | null; readonly electron_executable_included?: boolean; + /** Images named as the Electron host. Others may be helpers with no fuses. */ + readonly electron_host_paths?: readonly string[]; readonly fuse_images: readonly FuseImageFact[]; readonly go_images: readonly MetadataImageFact[]; readonly rust_images: readonly MetadataImageFact[]; @@ -578,13 +580,21 @@ const fuseCheck = ( : [], ); const failed = images.some((image) => image.kind === "failed"); + const hostUnresolved = images.some( + (image) => + image.kind === "report" && + image.path !== undefined && + owners.electron_host_paths?.includes(image.path) === true && + !(image.implications ?? []).some((item) => item.subject === spec.subject), + ); const partialFuseSample = nativeImagesMayBeUnread(scan) && owners.electron_executable_included !== true; let status: Status; if (outcomes.includes(spec.exposed)) status = "exposed"; else if (owners.electron_family === "not_electron") status = "not_applicable"; - else if (outcomes.includes("unknown") || failed) status = "unknown"; + else if (outcomes.includes("unknown") || failed || hostUnresolved) + status = "unknown"; else if (outcomes.length > 0 && outcomes.every((item) => item === spec.safe)) status = partialFuseSample ? "unknown" : "not_observed"; else if (owners.electron_family === "not_a_directory")