From d2aa0e09425324660786effdccddd1c1c0c34b95 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:57:03 -0800 Subject: [PATCH] fix(audit): read an app's sampled images for fuses A .app target sent only the bundle candidate to the fuse reader, which resolves to the outer Electron Framework. A sidecar with RunAsNode enabled was never read, so the check reported not_observed in the app view while the directory view reported exposed. Send the bundle candidate first and then the sampled native images. An image whose digest matches the bundle's read is skipped, so the framework is not read twice. A sidecar's safe fuses cannot settle the outer framework, so a bundle read with no answer for a fuse keeps that check unknown. --- .../ReleaseExposureService.test.ts | 118 +++++++++++++++--- .../releaseExposure/ReleaseExposureService.ts | 40 ++++-- src/artifacts/ReleaseExposureReader.ts | 16 +-- src/domain/releaseExposure.ts | 10 +- .../filesystem/releaseExposure.test.ts | 41 ++++++ 5 files changed, 187 insertions(+), 38 deletions(-) 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(); + } + }); +});