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.
This commit is contained in:
Matt Van Horn committed 2026-11-02 06:57:03 -08:00
1 parent 9b34735db7
commit d2aa0e0942
5 files changed
+187 -38

No files matched your search

@@ -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<typeof vi.fn>) =>
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(
@@ -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<string, string>,
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
+9 -7
View File
@@ -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,
+7 -3
View File
@@ -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 =
@@ -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();
}
});
});