diff --git a/src/application/releaseExposure/ReleaseExposureService.test.ts b/src/application/releaseExposure/ReleaseExposureService.test.ts index 3dee9a364f..b5ac8e9891 100644 --- a/src/application/releaseExposure/ReleaseExposureService.test.ts +++ b/src/application/releaseExposure/ReleaseExposureService.test.ts @@ -989,27 +989,29 @@ describe("ReleaseExposureService Electron host identity", () => { }); }); -describe("ReleaseExposureService Electron applicability", () => { - const appScan = scan({ - target: { - selected_path: "/work/App.app", - kind: "app", - format: "directory", - sha256: null, +const appScan = scan({ + target: { + selected_path: "/work/App.app", + kind: "app", + format: "directory", + sha256: null, + }, + fuse_candidates: [{ path: "/work/App.app", role: "app-bundle" }], +}); + +const frameworkDesktopReport = () => ({ + ...nativeDesktopReport(), + files: [ + { + ...electronDesktopReport().files[0], + path: "Contents/Frameworks/Electron Framework.framework/Electron Framework", + local_path: + "/work/App.app/Contents/Frameworks/Electron Framework.framework/Electron Framework", }, - fuse_candidates: [{ path: "/work/App.app", role: "app-bundle" }], - }); - const frameworkDesktopReport = () => ({ - ...nativeDesktopReport(), - files: [ - { - ...electronDesktopReport().files[0], - path: "Contents/Frameworks/Electron Framework.framework/Electron Framework", - local_path: - "/work/App.app/Contents/Frameworks/Electron Framework.framework/Electron Framework", - }, - ], - }); + ], +}); + +describe("ReleaseExposureService Electron applicability", () => { const noSentinel = ok(evidence(fuseReport([slice(0, { sentinels: 0 })]))); const unreadable = err(new AnalysisInputError("inspect_electron_fuses")); @@ -1139,6 +1141,82 @@ describe("ReleaseExposureService Electron applicability", () => { ); }); +describe("ReleaseExposureService app bundle with a sidecar", () => { + const noSentinel = ok(evidence(fuseReport([slice(0, { sentinels: 0 })]))); + const unreadable = err(new AnalysisInputError("inspect_electron_fuses")); + const FRAMEWORK = + "/work/App.app/Contents/Frameworks/Electron Framework.framework/Electron Framework"; + const SIDECAR = "/work/App.app/Contents/Resources/sidecar"; + const safe = ok(evidence(fuseReport([slice(0)]))); + const runAsNodeOn = ok( + evidence( + fuseReport([slice(0, { state: ELECTRON_FUSE_STATE_BYTES.enabled })]), + ), + ); + const bundleWithSidecar = () => { + const parts = owners({ + ...appScan, + native_images: [ + { path: FRAMEWORK, format: "mach-o", sha256: SHA }, + { path: SIDECAR, format: "mach-o" }, + ], + }); + parts.desktop.inspect.mockResolvedValue( + ok(evidence(frameworkDesktopReport())), + ); + return parts; + }; + const readPaths = (inspect: ReturnType) => + inspect.mock.calls.map((call) => (call[0] as { path: string }).path); + + it("reports RunAsNode exposed on the sidecar and reads the framework once", async () => { + const { service, electronFuses } = bundleWithSidecar(); + electronFuses.inspect.mockImplementation(async (input) => + (input as { path: string }).path === SIDECAR ? runAsNodeOn : safe, + ); + const check = await runAsNode(service, "/work/App.app"); + expect(check?.status).toBe("exposed"); + expect(JSON.stringify(check?.locations)).toContain(SIDECAR); + expect(readPaths(electronFuses.inspect)).toEqual([ + "/work/App.app", + SIDECAR, + ]); + }); + + it.each([ + ["sentinel-free", noSentinel], + [ + "unsupported", + err( + new AnalysisUnsupportedTargetError( + "inspect_electron_fuses", + "/work/App.app", + "unsupported", + ), + ), + ], + ] as const)( + "keeps a safe sidecar from standing for an %s framework", + async (_, framework) => { + const { service, electronFuses } = bundleWithSidecar(); + electronFuses.inspect.mockImplementation(async (input) => + (input as { path: string }).path === SIDECAR ? safe : framework, + ); + expect((await runAsNode(service, "/work/App.app"))?.status).toBe( + "unknown", + ); + }, + ); + + it("keeps safe outer fuses unknown when the sidecar cannot be read", async () => { + const { service, electronFuses } = bundleWithSidecar(); + electronFuses.inspect.mockImplementation(async (input) => + (input as { path: string }).path === SIDECAR ? unreadable : safe, + ); + expect((await runAsNode(service, "/work/App.app"))?.status).toBe("unknown"); + }); +}); + describe("ReleaseExposureService scanners and selected archives", () => { it("leaves fuse checks unknown on a selected ASAR because they were not read", async () => { const { service, desktop, electronFuses } = owners( diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts index c2b5ff8d21..e69d877f7d 100644 --- a/src/application/releaseExposure/ReleaseExposureService.ts +++ b/src/application/releaseExposure/ReleaseExposureService.ts @@ -205,7 +205,8 @@ export class ReleaseExposureService { ), ); const fuseImages: FuseImageFact[] = []; - for (const candidate of prepared.scan.fuse_candidates) + for (const candidate of prepared.scan.fuse_candidates) { + if (bundleReadCovers(candidate, digests, fuseImages)) continue; fuseImages.push( await settle(() => this.#fuseImage(candidate, digests, options), { kind: "failed", @@ -214,6 +215,7 @@ export class ReleaseExposureService { reason: OWNER_NOT_RUN, }), ); + } const family = fuseImagesRuleOutElectron( desktop.family, prepared.scan, @@ -346,6 +348,7 @@ export class ReleaseExposureService { kind: "report", role: candidate.role, path: candidate.path, + sha256: report.data.artifact.sha256, implications: fuseImplications(report.data), }); } @@ -541,6 +544,24 @@ const fuseImageChanged = ( return digests.size > 0 && ![...digests.values()].includes(sha256); }; +/** + * The bundle read lands on the framework file, which the sample may also list. + * The bundle candidate comes first, so that image needs no second read. + */ +const bundleReadCovers = ( + candidate: ReleaseExposureFuseCandidate, + digests: ReadonlyMap, + images: readonly FuseImageFact[], +): boolean => { + const digest = digests.get(candidate.path); + return ( + digest !== undefined && + images.some( + (image) => image.role === "app-bundle" && image.sha256 === digest, + ) + ); +}; + const imageChanged = ( image: ReleaseExposureNativeImage, sha256: string, @@ -710,13 +731,16 @@ const limitNativeSample = ( scan: { ...scan, native_images: kept, - fuse_candidates: - scan.target.kind === "app" - ? scan.fuse_candidates - : kept.map((image) => ({ - path: image.path, - role: "native-image" as const, - })), + // The bundle read covers one framework file, not the images beside it. + fuse_candidates: [ + ...scan.fuse_candidates.filter( + (candidate) => candidate.role === "app-bundle", + ), + ...kept.map((image) => ({ + path: image.path, + role: "native-image" as const, + })), + ], omitted: [ ...scan.omitted.filter((item) => item.subject !== "native images"), ...(omittedNative > 0 diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 0d9eeaa5a3..b445706226 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -612,13 +612,15 @@ const assembleScan = ( : "file", }, native_images: nativeImages, - fuse_candidates: - kind === "app" - ? [{ path: input.path, role: "app-bundle" }] - : nativeImages.map((image) => ({ - path: image.path, - role: "native-image" as const, - })), + fuse_candidates: [ + ...(kind === "app" + ? [{ path: input.path, role: "app-bundle" as const }] + : []), + ...nativeImages.map((image) => ({ + path: image.path, + role: "native-image" as const, + })), + ], source_maps: { embedded: state.embedded, without_sources: state.withoutSources, diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index 7d5ec77193..0777f9f29b 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -396,6 +396,8 @@ export interface FuseImageFact { readonly kind: "unsupported" | "failed" | "report"; readonly role: "app-bundle" | "native-image"; readonly path?: string; + /** Digest of the file the reader resolved, on a report. */ + readonly sha256?: string; readonly reason?: string; readonly implications?: readonly { readonly subject: string; @@ -583,11 +585,13 @@ const fuseCheck = ( : [], ); const failed = images.some((image) => image.kind === "failed"); + // A bundle's framework holds its fuses, so an image beside it cannot settle them. const hostUnresolved = images.some( (image) => - image.kind === "report" && - image.path !== undefined && - owners.electron_host_paths?.includes(image.path) === true && + image.kind !== "failed" && + (image.role === "app-bundle" || + (image.path !== undefined && + owners.electron_host_paths?.includes(image.path) === true)) && !(image.implications ?? []).some((item) => item.subject === spec.subject), ); const partialFuseSample = diff --git a/tests/boundary/filesystem/releaseExposure.test.ts b/tests/boundary/filesystem/releaseExposure.test.ts index d0470795cf..120a3c6ab3 100644 --- a/tests/boundary/filesystem/releaseExposure.test.ts +++ b/tests/boundary/filesystem/releaseExposure.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { createReleaseExposureService } from "../../../src/composition/releaseExposure.js"; import { releaseExposureReportSchema } from "../../../src/domain/releaseExposure.js"; +import { thinFuseImage } from "../../fixtures/electron/fuseImage.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("release exposure filesystem boundary", () => { @@ -134,3 +135,43 @@ describe("release exposure filesystem boundary", () => { } }); }); + +describe("release exposure app bundle boundary", () => { + it("reports a sidecar's RunAsNode fuse in an app bundle with safe outer fuses", async () => { + const root = await createTestTempDirectory("rea-release-exposure-app-"); + const app = join(root, "Sample.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + const resources = join(app, "Contents", "Resources"); + await mkdir(framework, { recursive: true }); + await mkdir(resources, { recursive: true }); + const disabled = 0x30; + const enabled = 0x31; + await writeFile( + join(framework, "Electron Framework"), + thinFuseImage(Array.from({ length: 6 }, () => disabled)).bytes, + ); + const sidecar = join(resources, "sidecar"); + await writeFile( + sidecar, + thinFuseImage([enabled, disabled, disabled, disabled, disabled, disabled]) + .bytes, + ); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path: app }); + if (!result.ok) throw new Error(result.error.message); + const check = releaseExposureReportSchema + .parse(result.value.normalized_result) + .checks.find((item) => item.id === "electron.run_as_node"); + expect(check?.status).toBe("exposed"); + expect(JSON.stringify(check?.locations)).toContain("sidecar"); + } finally { + await service.close(); + } + }); +});