mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user