diff --git a/docs/release-exposure.md b/docs/release-exposure.md index 5cf7d6431c..1a3b7859d7 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), 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 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. 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 fa2ee63a33..a4c46b6fc2 100644 --- a/src/application/releaseExposure/ReleaseExposureService.test.ts +++ b/src/application/releaseExposure/ReleaseExposureService.test.ts @@ -745,24 +745,36 @@ describe("ReleaseExposureService fuse sampling", () => { }); }); +const directoryScan = (paths: readonly string[]) => { + const images = paths.map((path) => ({ path, format: "elf" as const })); + return scan({ + target: { + selected_path: "/work/app", + kind: "directory", + format: "directory", + sha256: null, + }, + subject: { path: "/work/app", sha256: SHA, format: "directory" }, + native_images: images, + fuse_candidates: images.map((image) => ({ + path: image.path, + role: "native-image" as const, + })), + }); +}; + +const runAsNode = async ( + service: ReleaseExposureService, + path = "/work/app", +) => { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + return releaseExposureReportSchema + .parse(result.value.normalized_result) + .checks.find((check) => check.id === "electron.run_as_node"); +}; + describe("ReleaseExposureService Electron applicability", () => { - const directoryScan = (paths: readonly string[]) => { - const images = paths.map((path) => ({ path, format: "elf" as const })); - return scan({ - target: { - selected_path: "/work/app", - kind: "directory", - format: "directory", - sha256: null, - }, - subject: { path: "/work/app", sha256: SHA, format: "directory" }, - native_images: images, - fuse_candidates: images.map((image) => ({ - path: image.path, - role: "native-image" as const, - })), - }); - }; const appScan = scan({ target: { selected_path: "/work/App.app", @@ -783,16 +795,6 @@ describe("ReleaseExposureService Electron applicability", () => { }, ], }); - const runAsNode = async ( - service: ReleaseExposureService, - path = "/work/app", - ) => { - const result = await service.inspect({ path }); - if (!result.ok) throw result.error; - return releaseExposureReportSchema - .parse(result.value.normalized_result) - .checks.find((check) => check.id === "electron.run_as_node"); - }; const noSentinel = ok(evidence(fuseReport([slice(0, { sentinels: 0 })]))); const unreadable = err(new AnalysisInputError("inspect_electron_fuses")); @@ -806,6 +808,49 @@ describe("ReleaseExposureService Electron applicability", () => { expect((await runAsNode(service))?.status).toBe("not_observed"); }); + it("keeps safe fuses unknown when inventory may have missed the executable", async () => { + const { service, electronFuses } = owners({ + ...directoryScan(["/work/app/helper"]), + coverage: "unknown", + }); + electronFuses.inspect.mockResolvedValue( + ok(evidence(fuseReport([slice(0)]))), + ); + 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({ + target: { + selected_path: "/work/app", + kind: "executable", + format: "elf", + sha256: SHA, + }, + native_images: [{ path: "/work/app", format: "elf" }], + fuse_candidates: [{ path: "/work/app", role: "native-image" }], + coverage: "unknown", + }), + ); + electronFuses.inspect.mockResolvedValue( + ok(evidence(fuseReport([slice(0)]))), + ); + expect((await runAsNode(service))?.status).toBe("not_observed"); + }); + it("does not hide a failed fuse read behind a missing ASAR hint", async () => { const { service, electronFuses } = owners( directoryScan(["/work/app/electron"]), diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts index d6ac8eb898..a498077ae3 100644 --- a/src/application/releaseExposure/ReleaseExposureService.ts +++ b/src/application/releaseExposure/ReleaseExposureService.ts @@ -662,7 +662,11 @@ const limitNativeSample = ( const electronExecutableIncluded = scan.target.kind === "app" || scan.fuse_candidates.some((candidate) => candidate.role === "app-bundle") || - kept.some((image) => preferredPaths.has(image.path)); + kept.some( + (image) => + preferredPaths.has(image.path) || + image.path === scan.target.selected_path, + ); return { electronExecutableIncluded, scan: { diff --git a/src/domain/releaseExposure.test.ts b/src/domain/releaseExposure.test.ts index 99e9f98181..1e81a240e0 100644 --- a/src/domain/releaseExposure.test.ts +++ b/src/domain/releaseExposure.test.ts @@ -370,6 +370,37 @@ describe("release exposure fuse sampling", () => { ); expect(statusOf(included, "electron.run_as_node")).toBe("not_observed"); }); + + it("does not treat incomplete inventory as an absent fuse", () => { + const fuseFacts = { + electron_family: "electron" as const, + fuse_images: [ + { + kind: "report" as const, + role: "native-image" as const, + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + ], + }, + ], + }; + const incomplete = scan({ coverage: "unknown" }); + expect( + statusOf( + reportFor(incomplete, owners(fuseFacts)), + "electron.run_as_node", + ), + ).toBe("unknown"); + expect( + statusOf( + reportFor( + incomplete, + owners({ ...fuseFacts, electron_executable_included: true }), + ), + "electron.run_as_node", + ), + ).toBe("not_observed"); + }); }); describe("release exposure package checks", () => { diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index d86a3d07c7..41488daa51 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -562,10 +562,13 @@ const fuseCheck = ( : [], ); const failed = images.some((image) => image.kind === "failed"); - const partialFuseSample = + const unreadImagePossible = + scan.coverage === "unknown" || scan.omitted.some( (item) => item.subject === "native images" && item.count > 0, - ) && owners.electron_executable_included !== true; + ); + const partialFuseSample = + unreadImagePossible && 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";