mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
The package probe treated a decoded FAT table as a universal Mach-O. A JVM class past the 4 KiB probe shares the FAT magic, so its truncated prefix decoded as a table, the member counted as an unread native image, and Electron could not be ruled out of a complete scan. Pass the member's declared size to the sniff and take identity from classifyFatContainerEvidence. A member with no usable size, or with slices past the probe, stays counted as possibly native.
2867 lines
99 KiB
TypeScript
2867 lines
99 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { Readable } from "node:stream";
|
|
import { deflateRawSync } from "node:zlib";
|
|
import type { Dirent } from "node:fs";
|
|
import { mkdir, symlink, truncate, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js";
|
|
import { pe } from "../domain/binaryTarget.fixture.js";
|
|
import { nodeSeaElf } from "./nodeSea/NodeSea.fixture.js";
|
|
import {
|
|
bunModuleGraphPayload,
|
|
bunPrefixedPayload,
|
|
elfBunSection,
|
|
} from "./bun/BunStandalone.fixture.js";
|
|
import { dotnetBundleFixture } from "./dotnet/DotnetBundle.fixture.js";
|
|
import { cArchiveExecutable } from "./pyinstaller/CArchive.fixture.js";
|
|
import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
|
|
import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
|
|
import { javaClassFile } from "../../tests/fixtures/javaClassFile.js";
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import { universalMacho } from "../../tests/fixtures/universalMacho.js";
|
|
import { windowsNativeArtifactFixture } from "../../tests/fixtures/windowsNativeArtifact.js";
|
|
import {
|
|
ArtifactReaderFailure,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
} from "./ArtifactReader.js";
|
|
import {
|
|
assembleReleaseExposureReport,
|
|
MAX_RELEASE_EXPOSURE_INVENTORY_BYTES,
|
|
MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES,
|
|
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
|
|
MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES,
|
|
MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES,
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES,
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAPS,
|
|
MAX_RELEASE_EXPOSURE_TEXT_BYTES,
|
|
MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES,
|
|
PACKAGED_NATIVE_IMAGES,
|
|
type ReleaseExposureScan,
|
|
} from "../domain/releaseExposure.js";
|
|
import { HTML_INLINE_SCRIPT_LOCATION_LIMIT } from "../domain/javascript/htmlArtifactReferences.js";
|
|
import { ReleaseExposureReader } from "./ReleaseExposureReader.js";
|
|
|
|
const stableRead = vi.hoisted(() => ({
|
|
impl: undefined as
|
|
| undefined
|
|
| ((
|
|
...args: Parameters<
|
|
typeof import("./readStableArtifact.js").readStableArtifact
|
|
>
|
|
) => ReturnType<
|
|
typeof import("./readStableArtifact.js").readStableArtifact
|
|
>),
|
|
}));
|
|
|
|
vi.mock("./readStableArtifact.js", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("./readStableArtifact.js")
|
|
>("./readStableArtifact.js");
|
|
stableRead.impl = actual.readStableArtifact;
|
|
return {
|
|
...actual,
|
|
readStableArtifact: (
|
|
...args: Parameters<typeof actual.readStableArtifact>
|
|
) => (stableRead.impl ?? actual.readStableArtifact)(...args),
|
|
};
|
|
});
|
|
|
|
const inventoryRead = vi.hoisted(() => ({
|
|
impl: undefined as
|
|
| undefined
|
|
| ((
|
|
...args: Parameters<
|
|
typeof import("./inventory/ArtifactInventory.js").inventoryArtifact
|
|
>
|
|
) => ReturnType<
|
|
typeof import("./inventory/ArtifactInventory.js").inventoryArtifact
|
|
>),
|
|
}));
|
|
|
|
vi.mock("./inventory/ArtifactInventory.js", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("./inventory/ArtifactInventory.js")
|
|
>("./inventory/ArtifactInventory.js");
|
|
return {
|
|
...actual,
|
|
inventoryArtifact: (...args: Parameters<typeof actual.inventoryArtifact>) =>
|
|
(inventoryRead.impl ?? actual.inventoryArtifact)(...args),
|
|
};
|
|
});
|
|
|
|
const createdReaders = vi.hoisted(() => ({
|
|
wrap: undefined as
|
|
| undefined
|
|
| ((reader: ArtifactReader, path: string) => ArtifactReader),
|
|
}));
|
|
|
|
vi.mock("./createArtifactReader.js", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("./createArtifactReader.js")
|
|
>("./createArtifactReader.js");
|
|
return {
|
|
...actual,
|
|
createArtifactReader: (
|
|
...args: Parameters<typeof actual.createArtifactReader>
|
|
) => {
|
|
const reader = actual.createArtifactReader(...args);
|
|
if (reader === undefined || createdReaders.wrap === undefined)
|
|
return reader;
|
|
return createdReaders.wrap(reader, args[0]);
|
|
},
|
|
};
|
|
});
|
|
|
|
const regularFiles = vi.hoisted(() => ({
|
|
failClose: undefined as undefined | ((path: string) => boolean),
|
|
failUntil: 1,
|
|
closes: 0,
|
|
}));
|
|
|
|
vi.mock("../filesystem/RegularFile.js", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("../filesystem/RegularFile.js")
|
|
>("../filesystem/RegularFile.js");
|
|
return {
|
|
...actual,
|
|
openRegularFile: async (
|
|
...args: Parameters<typeof actual.openRegularFile>
|
|
) => {
|
|
const opened = await actual.openRegularFile(...args);
|
|
const close = opened.handle.close.bind(opened.handle);
|
|
opened.handle.close = async () => {
|
|
if (regularFiles.failClose?.(args[0]) === true) {
|
|
regularFiles.closes += 1;
|
|
if (regularFiles.closes <= regularFiles.failUntil)
|
|
throw new Error(`close failed: ${args[0]}`);
|
|
}
|
|
await close();
|
|
};
|
|
return opened;
|
|
},
|
|
};
|
|
});
|
|
|
|
const directoryReads = vi.hoisted(() => ({
|
|
count: 0,
|
|
failClose: false,
|
|
failedHandle: undefined as undefined | object,
|
|
closeHandles: [] as object[],
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
return {
|
|
...actual,
|
|
opendir: async (...args: Parameters<typeof actual.opendir>) => {
|
|
const directory = await actual.opendir(...args);
|
|
const read = directory.read.bind(
|
|
directory,
|
|
) as () => Promise<Dirent | null>;
|
|
const close = directory.close.bind(directory);
|
|
Object.assign(directory, {
|
|
close: async () => {
|
|
directoryReads.closeHandles.push(directory);
|
|
if (directoryReads.failClose) {
|
|
directoryReads.failedHandle = directory;
|
|
await close();
|
|
throw new Error("sibling directory close failed");
|
|
}
|
|
await close();
|
|
},
|
|
read: () => {
|
|
directoryReads.count += 1;
|
|
return read();
|
|
},
|
|
});
|
|
return directory;
|
|
},
|
|
};
|
|
});
|
|
|
|
const PLANTED_SOURCE = "release-exposure-planted-secret-7c2e9a";
|
|
const PLANTED_KEY = "AKIAPLANT9EXPOSUREXX";
|
|
const ignoreText = (): void => undefined;
|
|
|
|
describe("ReleaseExposureReader review regressions", () => {
|
|
it("keeps inline CSS source-map coverage unknown in files and packages", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-css-");
|
|
const source = `body { color: red; }\n/*# sourceMappingURL=data:application/json;base64,${embeddedSourceMap().toString("base64")} */`;
|
|
const path = join(root, "app.css");
|
|
await writeFile(path, source);
|
|
for (const target of [path, root]) {
|
|
expect((await inspect(target)).source_maps).toMatchObject({
|
|
embedded: 0,
|
|
skipped: 1,
|
|
});
|
|
}
|
|
const archive = join(root, "app.asar");
|
|
await writeFile(
|
|
archive,
|
|
asarArchive([{ name: "app.css", bytes: Buffer.from(source) }]),
|
|
);
|
|
expect((await inspect(archive)).source_maps.skipped).toBe(1);
|
|
await writeFile(
|
|
path,
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
);
|
|
expect((await inspect(path)).source_maps.skipped).toBe(1);
|
|
});
|
|
|
|
it.each(["data", "DATA"])(
|
|
"inspects embedded sources in inline JavaScript maps with a %s: scheme",
|
|
async (scheme) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-inline-",
|
|
);
|
|
const source = `const value = 1;\n//# sourceMappingURL=${scheme}:application/json;base64,${embeddedSourceMap().toString("base64")}`;
|
|
await writeFile(join(root, "app.js"), source);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect(scanned.source_maps.embedded_paths).toEqual([
|
|
join(root, "app.js"),
|
|
]);
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([{ name: "app.js", bytes: Buffer.from(source) }]),
|
|
);
|
|
const packaged = await inspect(join(root, "app.asar"));
|
|
expect(packaged.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
},
|
|
);
|
|
|
|
it.each([80, 200, 520, 550])(
|
|
"preserves CodeView parsing truncated at %s bytes as unresolved",
|
|
async (length) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-truncated-",
|
|
);
|
|
const bytes = pe(0x8664, 64, 0x0002, false, {
|
|
pdbPath: "C:\\build\\app.pdb",
|
|
});
|
|
await writeFile(join(root, "app.exe"), bytes.subarray(0, length));
|
|
const scanned = await inspect(root);
|
|
expect(scanned.pdb).toMatchObject({ failed: 1, paths: [] });
|
|
},
|
|
);
|
|
|
|
it("distinguishes an absent CodeView directory from an out-of-bounds record", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-codeview-",
|
|
);
|
|
const path = join(root, "app.exe");
|
|
await writeFile(path, pe(0x8664));
|
|
expect((await inspect(path)).pdb).toMatchObject({
|
|
examined: 1,
|
|
failed: 0,
|
|
paths: [],
|
|
});
|
|
const bytes = pe(0x8664, 64, 0x0002, false, { pdbPath: "app.pdb" });
|
|
bytes.writeUInt32LE(bytes.length + 100, 512 + 24);
|
|
await writeFile(path, bytes);
|
|
expect((await inspect(path)).pdb).toMatchObject({
|
|
examined: 1,
|
|
failed: 1,
|
|
paths: [],
|
|
});
|
|
});
|
|
|
|
it.each(["base64", "percent"])(
|
|
"inspects inline maps without sources using %s encoding",
|
|
async (encoding) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-plain-inline-",
|
|
);
|
|
const map = JSON.stringify({
|
|
version: 3,
|
|
sources: ["app.js"],
|
|
names: [],
|
|
mappings: "",
|
|
});
|
|
const url =
|
|
encoding === "base64"
|
|
? `data:application/json;base64,${Buffer.from(map).toString("base64")}`
|
|
: `data:application/json,${encodeURIComponent(map)}`;
|
|
const path = join(root, "app.js");
|
|
await writeFile(path, `/*# sourceMappingURL=${url} */`);
|
|
expect((await inspect(path)).source_maps).toMatchObject({
|
|
without_sources: 1,
|
|
embedded: 0,
|
|
});
|
|
await writeFile(
|
|
path,
|
|
`const documentation = ${JSON.stringify(`//# sourceMappingURL=${url}`)};`,
|
|
);
|
|
expect((await inspect(path)).source_maps).toMatchObject({
|
|
without_sources: 0,
|
|
embedded: 0,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves unknown coverage for malformed inline maps and skipped JavaScript", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-inline-unknown-",
|
|
);
|
|
const path = join(root, "app.js");
|
|
await writeFile(
|
|
path,
|
|
"//# sourceMappingURL=data:application/json;base64,bm90IGpzb24=",
|
|
);
|
|
expect((await inspect(path)).source_maps.unparsed).toBe(1);
|
|
await writeFile(
|
|
path,
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
);
|
|
expect((await inspect(path)).source_maps.skipped).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader", () => {
|
|
it("counts embedded sources and markers without retaining their bytes", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-read-");
|
|
const source = `export const token = "${PLANTED_SOURCE}";\n`;
|
|
await writeFile(
|
|
join(root, "app.js.map"),
|
|
JSON.stringify({
|
|
version: 3,
|
|
file: "app.js",
|
|
sources: ["app.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: [source],
|
|
}),
|
|
);
|
|
await writeFile(join(root, "app.js"), "export const token = 1;\n");
|
|
await writeFile(join(root, "app.pdb"), "pdb");
|
|
await writeFile(
|
|
join(root, "notes.txt"),
|
|
`-----BEGIN PRIVATE KEY-----\n${PLANTED_KEY}\n-----END PRIVATE KEY-----\n`,
|
|
);
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const visited = new Map<string, string>();
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
(text, location) => visited.set(location, text),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.source_maps.embedded).toBe(1);
|
|
expect(
|
|
result.value.debug_companions.paths.some((path) =>
|
|
path.endsWith(".pdb"),
|
|
),
|
|
).toBe(true);
|
|
expect(visited.get(join(root, "notes.txt"))).toContain(PLANTED_KEY);
|
|
const serialized = JSON.stringify(result.value);
|
|
expect(serialized).not.toContain(PLANTED_SOURCE);
|
|
expect(serialized).not.toContain(PLANTED_KEY);
|
|
expect(serialized).not.toContain("BEGIN PRIVATE KEY");
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("reads a source map inside an ASAR, including one stored under a directory", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-asar-");
|
|
const archive = sourceMapArchive();
|
|
await writeFile(join(root, "app.asar"), archive);
|
|
const selected = await inspect(join(root, "app.asar"));
|
|
expect(selected.source_maps.embedded).toBe(1);
|
|
expect(selected.extractable.formats).toContain("asar");
|
|
expect(selected.extractable.readable_members).toBeGreaterThan(0);
|
|
expect(selected.textLocations).toContain("dist/notes.txt");
|
|
expect(JSON.stringify(selected)).not.toContain(PLANTED_SOURCE);
|
|
expect(JSON.stringify(selected)).not.toContain(PLANTED_KEY);
|
|
|
|
const bundled = await createTestTempDirectory("rea-release-exposure-app-");
|
|
await mkdir(join(bundled, "resources"), { recursive: true });
|
|
await writeFile(join(bundled, "resources", "app.asar"), archive);
|
|
const directory = await inspect(bundled);
|
|
expect(directory.source_maps.embedded).toBe(1);
|
|
expect(directory.extractable.formats).toContain("asar");
|
|
expect(directory.extractable.readable_members).toBeGreaterThan(0);
|
|
expect(directory.textLocations).toContain(
|
|
"resources/app.asar/dist/notes.txt",
|
|
);
|
|
expect(JSON.stringify(directory)).not.toContain(PLANTED_SOURCE);
|
|
expect(JSON.stringify(directory)).not.toContain(PLANTED_KEY);
|
|
});
|
|
|
|
it("carries the inventoried digest of each native image", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-digest-");
|
|
const elf = Buffer.alloc(64);
|
|
elf.set([0x7f, 0x45, 0x4c, 0x46], 0);
|
|
await writeFile(join(root, "app"), elf);
|
|
const sha256 = createHash("sha256").update(elf).digest("hex");
|
|
expect((await inspect(root)).native_images).toEqual([
|
|
{ path: join(root, "app"), format: "elf", sha256 },
|
|
]);
|
|
expect((await inspect(join(root, "app"))).native_images).toEqual([
|
|
{ path: join(root, "app"), format: "elf", sha256 },
|
|
]);
|
|
});
|
|
|
|
it("registers native images larger than the text budget and parses PE debug records", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-native-");
|
|
const elf = Buffer.alloc(300 * 1024);
|
|
elf[0] = 0x7f;
|
|
elf[1] = 0x45;
|
|
elf[2] = 0x4c;
|
|
elf[3] = 0x46;
|
|
await writeFile(join(root, "app.elf"), elf);
|
|
const pe = Buffer.concat([
|
|
windowsNativeArtifactFixture().bytes,
|
|
Buffer.alloc(300 * 1024),
|
|
]);
|
|
await writeFile(join(root, "app.exe"), pe);
|
|
const calls: string[] = [];
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
calls.push(args[0]);
|
|
if (real === undefined) throw new Error("missing stable read");
|
|
return real(...args);
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(
|
|
scanned.native_images.some(
|
|
(image) => image.format === "elf" && image.path.endsWith("app.elf"),
|
|
),
|
|
).toBe(true);
|
|
expect(scanned.pdb.examined).toBeGreaterThanOrEqual(1);
|
|
expect(calls.some((path) => path.endsWith("app.elf"))).toBe(false);
|
|
expect(calls.some((path) => path.endsWith("app.exe"))).toBe(true);
|
|
} finally {
|
|
stableRead.impl = real;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader member reads", () => {
|
|
it("reads an uncompressed member from a package larger than 8 MiB", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-bundle-");
|
|
const bundle = dotnetBundleFixture([
|
|
{ path: "app.js", bytes: Buffer.from("console.log(1)\n") },
|
|
]);
|
|
await writeFile(
|
|
join(root, "app.dll"),
|
|
Buffer.concat([bundle.bytes, Buffer.alloc(8 * 1024 * 1024)]),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.extractable.formats).toContain("dotnet-bundle");
|
|
expect(scanned.extractable.readable_members).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("inflates a compressed source map instead of scanning compressed bytes", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-deflate-");
|
|
const map = Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
file: "app.js",
|
|
sources: ["app.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: [`export const secret = "${PLANTED_SOURCE}";\n`],
|
|
}),
|
|
);
|
|
const bundle = dotnetBundleFixture([
|
|
{ path: "app.js.map", bytes: map, compressed: true },
|
|
]);
|
|
await writeFile(join(root, "app.dll"), bundle.bytes);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.embedded).toBe(1);
|
|
expect(JSON.stringify(scanned)).not.toContain(PLANTED_SOURCE);
|
|
});
|
|
|
|
it("leaves a corrupt JavaScript source map unparsed", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-broken-");
|
|
await writeFile(join(root, "broken.js.map"), "{not json");
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.unparsed).toBe(1);
|
|
expect(scanned.source_maps.embedded).toBe(0);
|
|
expect(scanned.source_maps.without_sources).toBe(0);
|
|
expect(
|
|
scanned.debug_companions.paths.some((path) =>
|
|
path.endsWith("broken.js.map"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("counts an unreadable map or text member as skipped", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-unreadable-",
|
|
);
|
|
await writeFile(join(root, "broken.js.map"), "{}\n");
|
|
await writeFile(join(root, "notes.txt"), "hello\n");
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
const path = String(args[0]);
|
|
if (path.endsWith("broken.js.map") || path.endsWith("notes.txt"))
|
|
throw new Error("unreadable member");
|
|
if (real === undefined) throw new Error("missing stable read");
|
|
return real(...args);
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.skipped).toBeGreaterThan(0);
|
|
expect(scanned.text_members.skipped).toBeGreaterThan(0);
|
|
expect(scanned.omitted.map((item) => item.subject)).toEqual(
|
|
expect.arrayContaining(["source maps", "text members"]),
|
|
);
|
|
} finally {
|
|
stableRead.impl = real;
|
|
}
|
|
});
|
|
|
|
it("keeps a linker map out of the JavaScript source-map counts", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-link-");
|
|
await writeFile(
|
|
join(root, "foo.map"),
|
|
"Archive member included to satisfy linker\n",
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.unparsed).toBe(0);
|
|
expect(
|
|
scanned.debug_companions.paths.some((path) => path.endsWith("foo.map")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each(["external section", "decoder limit"])(
|
|
"keeps a generic map with an unsupported %s unresolved",
|
|
async (scenario) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-map-");
|
|
let map: unknown = { version: 3, sources: [], mappings: "" };
|
|
if (scenario === "external section")
|
|
map = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, url: "external.map" }],
|
|
};
|
|
else
|
|
for (let depth = 0; depth < 66; depth += 1)
|
|
map = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, map }],
|
|
};
|
|
await writeFile(join(root, "bundle.map"), JSON.stringify(map));
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
unparsed: 1,
|
|
embedded: 0,
|
|
without_sources: 0,
|
|
});
|
|
expect(scanned.debug_companions.paths).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("does not treat a Java class file as a universal Mach-O", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-class-");
|
|
await writeFile(join(root, "Main.class"), javaClassFile());
|
|
const scanned = await inspect(root);
|
|
expect(
|
|
scanned.native_images.some(
|
|
(image) => image.format === "mach-o-universal",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("refuses a directory member whose bytes change after inventory", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-stable-");
|
|
await writeFile(join(root, "notes.txt"), "plain text\n");
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
if (real === undefined) throw new Error("missing stable read");
|
|
const snapshot = await real(...args);
|
|
if (args[0].endsWith("notes.txt"))
|
|
return { bytes: snapshot.bytes, sha256: "ff".repeat(32) };
|
|
return snapshot;
|
|
};
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("Expected a failed audit.");
|
|
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
|
|
} finally {
|
|
stableRead.impl = real;
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader limits", () => {
|
|
it("counts sourcesContent nested past the old depth-8 walker", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-depth-");
|
|
let map: unknown = {
|
|
version: 3,
|
|
file: "leaf.js",
|
|
sources: ["leaf.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: ["const leaf = 1;\n"],
|
|
};
|
|
for (let depth = 0; depth < 9; depth += 1)
|
|
map = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, map }],
|
|
};
|
|
await writeFile(join(root, "deep.js.map"), JSON.stringify(map));
|
|
await writeFile(
|
|
join(root, "trailing.js.map"),
|
|
JSON.stringify({
|
|
version: 3,
|
|
file: "a.js",
|
|
sources: ["a.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: [null, "ignored because it is past sources"],
|
|
}),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.embedded).toBe(1);
|
|
expect(scanned.source_maps.without_sources).toBe(1);
|
|
});
|
|
|
|
it("caps reported limitations when many members cannot be read", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-cap-");
|
|
for (let index = 0; index < 12; index += 1)
|
|
await writeFile(join(root, `note-${String(index)}.txt`), "text\n");
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
if (String(args[0]).endsWith(".txt"))
|
|
throw new Error("unreadable member");
|
|
if (real === undefined) throw new Error("missing stable read");
|
|
return real(...args);
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(scanned.limitations).toHaveLength(8);
|
|
} finally {
|
|
stableRead.impl = real;
|
|
}
|
|
});
|
|
|
|
it("refuses a file whose inventory reports the bytes changed", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-integrity-",
|
|
);
|
|
const target = join(root, "app.bin");
|
|
await writeFile(target, "stable\n");
|
|
inventoryRead.impl = () => {
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Artifact changed during inventory: ${target}`,
|
|
);
|
|
};
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: target },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("Expected a failed audit.");
|
|
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("counts members past the package entry cap and marks coverage unknown", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-entries-");
|
|
const files = [
|
|
...Array.from({ length: 4096 }, (_, index) => ({
|
|
path: `f${String(index)}.bin`,
|
|
bytes: Buffer.from("x"),
|
|
})),
|
|
{ path: "hidden.js.map", bytes: Buffer.from("{not json") },
|
|
{ path: "secret.txt", bytes: Buffer.from("hello\n") },
|
|
];
|
|
await writeFile(join(root, "app.dll"), dotnetBundleFixture(files).bytes);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(scanned.source_maps.skipped).toBeGreaterThan(0);
|
|
expect(scanned.text_members.skipped).toBeGreaterThan(0);
|
|
expect(scanned.omitted.map((item) => item.subject)).toEqual(
|
|
expect.arrayContaining(["source maps", "text members"]),
|
|
);
|
|
}, 60_000);
|
|
|
|
it("does not drop native images before the service samples them", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-natives-");
|
|
for (let index = 0; index < 5; index += 1) {
|
|
const elf = Buffer.alloc(64);
|
|
elf[0] = 0x7f;
|
|
elf[1] = 0x45;
|
|
elf[2] = 0x4c;
|
|
elf[3] = 0x46;
|
|
await writeFile(join(root, `lib${String(index)}.so`), elf);
|
|
}
|
|
const scanned = await inspect(root);
|
|
expect(scanned.native_images).toHaveLength(5);
|
|
expect(
|
|
scanned.omitted.some((item) => item.subject === "native images"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("locates a CodeView path on the PE image that contains it", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-pdb-");
|
|
await writeFile(
|
|
join(root, "app.exe"),
|
|
pe(0x8664, 64, 0x0002, false, { pdbPath: "C:\\build\\app.pdb" }),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.pdb.paths).toContain("C:\\build\\app.pdb");
|
|
expect(
|
|
scanned.pdb.image_paths?.some((path) => path.endsWith("app.exe")),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader package cleanup", () => {
|
|
it("rejects a replaced inventoried package before listing and closes its reader", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-replaced-",
|
|
);
|
|
const archive = join(root, "app.asar");
|
|
await writeFile(archive, sourceMapArchive());
|
|
const actual = await vi.importActual<
|
|
typeof import("./inventory/ArtifactInventory.js")
|
|
>("./inventory/ArtifactInventory.js");
|
|
let inventoried = false;
|
|
inventoryRead.impl = async (...args) => {
|
|
const inventory = await actual.inventoryArtifact(...args);
|
|
await writeFile(
|
|
archive,
|
|
asarArchive([
|
|
{
|
|
name: "dist/app.js.map",
|
|
bytes: Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
sources: [],
|
|
names: [],
|
|
mappings: "",
|
|
}),
|
|
),
|
|
},
|
|
]),
|
|
);
|
|
inventoried = true;
|
|
return inventory;
|
|
};
|
|
let created = 0;
|
|
let listed = false;
|
|
let closed = false;
|
|
createdReaders.wrap = (source, path) => {
|
|
if (path !== archive || !inventoried) return source;
|
|
created += 1;
|
|
const entries = source.entries.bind(source);
|
|
source.entries = (...args) => {
|
|
listed = true;
|
|
return entries(...args);
|
|
};
|
|
const close = source.close.bind(source);
|
|
source.close = async () => {
|
|
closed = true;
|
|
await close();
|
|
};
|
|
return source;
|
|
};
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("Expected a failed audit.");
|
|
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
|
|
expect(created).toBe(1);
|
|
expect(listed).toBe(false);
|
|
expect(closed).toBe(true);
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
createdReaders.wrap = undefined;
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"reports package cleanup failure and retains the scan or primary error (scan fails: %s)",
|
|
async (scanFails) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-close-");
|
|
const archive = join(root, "app.asar");
|
|
await writeFile(archive, sourceMapArchive());
|
|
let created = 0;
|
|
let closes = 0;
|
|
createdReaders.wrap = (reader, path) => {
|
|
if (!path.endsWith("app.asar")) return reader;
|
|
created += 1;
|
|
if (created === 1) return reader;
|
|
const original = reader.close.bind(reader);
|
|
let attempts = 0;
|
|
reader.close = async () => {
|
|
attempts += 1;
|
|
closes += 1;
|
|
if (attempts < 3) throw new Error(`close failed: ${path}`);
|
|
await original();
|
|
};
|
|
return reader;
|
|
};
|
|
const reader = new ReleaseExposureReader();
|
|
const primary = new AnalysisArtifactChangedError(
|
|
"audit_release_exposure",
|
|
archive,
|
|
"scan target changed",
|
|
);
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: archive },
|
|
undefined,
|
|
scanFails
|
|
? () => {
|
|
throw primary;
|
|
}
|
|
: ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("Expected a failed audit.");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources).toContain(archive);
|
|
if (scanFails)
|
|
expect(result.error.diagnostics?.primary_error).toMatchObject({
|
|
code: "artifact_changed",
|
|
details: { reason: "scan target changed" },
|
|
});
|
|
else
|
|
expect(result.error.diagnostics?.primary_error).toMatchObject({
|
|
details: {
|
|
diagnostics: { scan: { source_maps: { embedded: 1 } } },
|
|
},
|
|
});
|
|
await expect(reader.close()).rejects.toSatisfy((error: unknown) => {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error);
|
|
const resources =
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"cleanupResources" in error
|
|
? (error as { cleanupResources: readonly string[] })
|
|
.cleanupResources
|
|
: [];
|
|
return (
|
|
message.includes("app.asar") ||
|
|
resources.some((resource) => resource.includes("app.asar"))
|
|
);
|
|
});
|
|
expect(closes).toBeGreaterThan(1);
|
|
await reader.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
await reader.close().catch(() => undefined);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("ReleaseExposureReader package allowances", () => {
|
|
it("stops opening embedded packages after the container allowance", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-containers-",
|
|
);
|
|
for (let index = 0; index < 9; index += 1) {
|
|
const bundle = dotnetBundleFixture([
|
|
{ path: "app.js", bytes: Buffer.from("console.log(1)\n") },
|
|
]);
|
|
await writeFile(join(root, `app-${String(index)}.dll`), bundle.bytes);
|
|
}
|
|
const scanned = await inspect(root);
|
|
expect(scanned.extractable.readable_members).toBe(8);
|
|
expect(scanned.extractable.unreadable_members).toBeGreaterThan(0);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(scanned.limitations.length).toBeLessThanOrEqual(8);
|
|
expect(scanned.omitted).toContainEqual(
|
|
expect.objectContaining({ subject: "embedded package probes", count: 1 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader mixed maps and sibling paths", () => {
|
|
it.each([false, true])(
|
|
"retains embedded sources beside an external section (external first: %s)",
|
|
async (externalFirst) => {
|
|
const root = await createTestTempDirectory("rea-release-mixed-map-");
|
|
const path = join(root, "app.js.map");
|
|
const sections = [
|
|
{ map: JSON.parse(embeddedSourceMap().toString("utf8")) as unknown },
|
|
{ url: "external.map" },
|
|
];
|
|
if (externalFirst) sections.reverse();
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({
|
|
version: 3,
|
|
sections: sections.map((section, line) => ({
|
|
offset: { line, column: 0 },
|
|
...section,
|
|
})),
|
|
}),
|
|
);
|
|
const scanned = await inspect(path);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 1,
|
|
embedded_paths: [path],
|
|
without_sources: 0,
|
|
unparsed: 1,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps an embedded leaf beside a leaf with malformed mappings", async () => {
|
|
const root = await createTestTempDirectory("rea-release-malformed-leaf-");
|
|
const path = join(root, "app.js.map");
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({
|
|
version: 3,
|
|
sections: [
|
|
{
|
|
offset: { line: 0, column: 0 },
|
|
map: JSON.parse(embeddedSourceMap().toString("utf8")) as unknown,
|
|
},
|
|
{
|
|
offset: { line: 1, column: 0 },
|
|
map: { version: 3, sources: ["b.js"], names: [], mappings: "AA" },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const scanned = await inspect(path);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 1,
|
|
embedded_paths: [path],
|
|
unparsed: 1,
|
|
});
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("does not expand sections past the decoder depth budget", async () => {
|
|
const root = await createTestTempDirectory("rea-release-deep-map-");
|
|
const path = join(root, "app.js.map");
|
|
const leaf = JSON.parse(embeddedSourceMap().toString("utf8")) as unknown;
|
|
let deep: unknown = leaf;
|
|
for (let depth = 0; depth < 70; depth += 1)
|
|
deep = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, map: deep }],
|
|
};
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({
|
|
version: 3,
|
|
sections: [
|
|
{ offset: { line: 0, column: 0 }, map: deep },
|
|
{ offset: { line: 1, column: 0 }, map: leaf },
|
|
],
|
|
}),
|
|
);
|
|
const scanned = await inspect(path);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 1,
|
|
embedded_paths: [path],
|
|
unparsed: 1,
|
|
});
|
|
await writeFile(path, JSON.stringify(deep));
|
|
expect((await inspect(path)).source_maps).toMatchObject({
|
|
embedded: 0,
|
|
unparsed: 1,
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves symlink/.. traversal for linker maps and named companions",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-release-sibling-path-");
|
|
await mkdir(join(root, "actual", "child"), { recursive: true });
|
|
await symlink(join(root, "actual", "child"), join(root, "link"));
|
|
await writeFile(join(root, "actual", "app"), "plain file");
|
|
await writeFile(join(root, "actual", "app.map"), "Linker map\n");
|
|
await writeFile(join(root, "actual", "app.pdb"), "debug companion");
|
|
const parent = `${root}/link/..`;
|
|
const scanned = await inspect(`${parent}/app`);
|
|
expect(scanned.debug_companions).toEqual({
|
|
paths: expect.arrayContaining([
|
|
`${parent}/app.map`,
|
|
`${parent}/app.pdb`,
|
|
]),
|
|
skipped: 0,
|
|
});
|
|
expect(scanned.debug_companions.paths).toHaveLength(2);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("ReleaseExposureReader acquisition and sibling review regressions", () => {
|
|
it.each(["external section", "decoder limit", "malformed JSON"])(
|
|
"keeps a sibling map with an unsupported %s unresolved",
|
|
async (scenario) => {
|
|
const root = await createTestTempDirectory("rea-release-sibling-map-");
|
|
let map: unknown = { version: 3, sources: [], mappings: "" };
|
|
if (scenario === "external section")
|
|
map = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, url: "external.map" }],
|
|
};
|
|
else if (scenario === "decoder limit")
|
|
for (let depth = 0; depth < 66; depth += 1)
|
|
map = {
|
|
version: 3,
|
|
sections: [{ offset: { line: 0, column: 0 }, map }],
|
|
};
|
|
await writeFile(
|
|
join(root, "bundle.map"),
|
|
scenario === "malformed JSON" ? "{broken" : JSON.stringify(map),
|
|
);
|
|
await writeFile(join(root, "app"), "plain file");
|
|
const scanned = await inspect(join(root, "app"));
|
|
expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 });
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 0,
|
|
without_sources: 0,
|
|
unparsed: 0,
|
|
skipped: 0,
|
|
samples: [],
|
|
});
|
|
},
|
|
);
|
|
it("bounds package inventory acquisition before hashing members past the entry cap", async () => {
|
|
const entryCap = 3;
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-inventory-entries-",
|
|
);
|
|
const archive = join(root, "app.asar");
|
|
await writeFile(
|
|
archive,
|
|
asarArchive(
|
|
Array.from({ length: entryCap + 1 }, (_, index) => ({
|
|
name: `member${String(index)}.bin`,
|
|
bytes: Buffer.from("x"),
|
|
})),
|
|
),
|
|
);
|
|
let opened = 0;
|
|
const actual = await vi.importActual<
|
|
typeof import("./inventory/ArtifactInventory.js")
|
|
>("./inventory/ArtifactInventory.js");
|
|
// Exercise real acquisition with a small cap instead of hashing 65,536
|
|
// members; verify the production allowance is forwarded separately.
|
|
const acquire = vi.fn<typeof actual.inventoryArtifact>((path, options) =>
|
|
actual.inventoryArtifact(path, {
|
|
...options,
|
|
acquisitionLimits: {
|
|
maximumEntries: entryCap,
|
|
maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES,
|
|
},
|
|
}),
|
|
);
|
|
inventoryRead.impl = acquire;
|
|
let inventoryReader: ArtifactReader | undefined;
|
|
createdReaders.wrap = (source) => {
|
|
if (inventoryReader !== undefined) return source;
|
|
inventoryReader = source;
|
|
const open = source.open.bind(source);
|
|
source.open = (...args) => {
|
|
opened += 1;
|
|
return open(...args);
|
|
};
|
|
return source;
|
|
};
|
|
try {
|
|
const scanned = await inspect(archive);
|
|
expect(acquire).toHaveBeenCalledExactlyOnceWith(
|
|
archive,
|
|
expect.objectContaining({
|
|
acquisitionLimits: {
|
|
maximumEntries: MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES,
|
|
maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES,
|
|
},
|
|
}),
|
|
);
|
|
expect(opened).toBe(entryCap);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(scanned.limitations.join("\n")).toContain(
|
|
"acquisition allowance exhausted",
|
|
);
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
});
|
|
|
|
it("bounds directory inventory bytes before opening an oversized member", async () => {
|
|
const root = await createTestTempDirectory("rea-release-inventory-bytes-");
|
|
await writeFile(join(root, "large.bin"), "");
|
|
await truncate(
|
|
join(root, "large.bin"),
|
|
MAX_RELEASE_EXPOSURE_INVENTORY_BYTES + 1,
|
|
);
|
|
const reader = new ReleaseExposureReader();
|
|
let opened = 0;
|
|
createdReaders.wrap = (source) => {
|
|
const open = source.open.bind(source);
|
|
source.open = (...args) => {
|
|
opened += 1;
|
|
return open(...args);
|
|
};
|
|
return source;
|
|
};
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(opened).toBe(0);
|
|
expect(result.value.coverage).toBe("unknown");
|
|
expect(result.value.limitations.join("\n")).toContain(
|
|
"acquisition allowance exhausted",
|
|
);
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader member allowances", () => {
|
|
it.each(["unrelated.js.map", "unrelated.map"])(
|
|
"keeps sibling source map %s out of the selected artifact's counts",
|
|
async (name) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-sibling-",
|
|
);
|
|
await writeFile(join(root, "app"), "plain file");
|
|
await writeFile(join(root, name), embeddedSourceMap());
|
|
const scanned = await inspect(join(root, "app"));
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 0,
|
|
without_sources: 0,
|
|
unparsed: 0,
|
|
skipped: 0,
|
|
samples: [],
|
|
});
|
|
expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 });
|
|
expect(scanned.coverage).toBe("complete");
|
|
},
|
|
);
|
|
|
|
it("keeps a later source map readable after many large binary members", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-budget-");
|
|
const binary = Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES);
|
|
const oversizedScript = Buffer.alloc(
|
|
MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1,
|
|
0x20,
|
|
);
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([
|
|
...Array.from({ length: 160 }, (_, index) => ({
|
|
name: `a${String(index)}.bin`,
|
|
bytes: binary,
|
|
})),
|
|
{ name: "b.js", bytes: oversizedScript },
|
|
{ name: "z.js.map", bytes: embeddedSourceMap() },
|
|
]),
|
|
);
|
|
const scanned = await inspect(join(root, "app.asar"));
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 1 });
|
|
expect(scanned.extractable.readable_members).toBe(162);
|
|
}, 60_000);
|
|
|
|
it("reports an oversized linker map as a debug companion wherever it ships", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-link-");
|
|
const linkerMap = Buffer.alloc(
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1,
|
|
0x20,
|
|
);
|
|
linkerMap.write("Archive member included to satisfy reference\n");
|
|
await mkdir(join(root, "bin"));
|
|
await writeFile(join(root, "bin", "app.map"), linkerMap);
|
|
await writeFile(join(root, "bin", "app"), "not a package");
|
|
const directory = await inspect(join(root, "bin"));
|
|
expect(directory.debug_companions.paths).toEqual([
|
|
expect.stringMatching(/bin\/app\.map$/u),
|
|
]);
|
|
expect(directory.source_maps.skipped).toBe(0);
|
|
|
|
const selected = await inspect(join(root, "bin", "app"));
|
|
expect(selected.debug_companions.paths).toEqual([
|
|
join(root, "bin", "app.map"),
|
|
]);
|
|
expect(selected.source_maps.skipped).toBe(0);
|
|
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([{ name: "app.map", bytes: linkerMap }]),
|
|
);
|
|
const packaged = await inspect(join(root, "app.asar"));
|
|
expect(packaged.debug_companions.paths).toEqual(["app.map"]);
|
|
expect(packaged.source_maps.skipped).toBe(0);
|
|
});
|
|
|
|
it("leaves an oversized JSON .map undecided as both map kinds", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-json-");
|
|
const map = Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, 0x20);
|
|
map.write('{"version":3,"sources":[]}');
|
|
await writeFile(join(root, "bundle.map"), map);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 });
|
|
expect(scanned.source_maps.skipped).toBe(1);
|
|
expect(scanned.text_members.skipped).toBe(1);
|
|
});
|
|
|
|
it("keeps whitespace-only prefixes of oversized maps unresolved", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-spaces-");
|
|
await writeFile(
|
|
join(root, "bundle.map"),
|
|
Buffer.concat([
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, 0x20),
|
|
embeddedSourceMap(),
|
|
]),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.skipped).toBe(1);
|
|
expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 });
|
|
});
|
|
|
|
it("does not spend the source-map allowance reading a JavaScript map past the count", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-count-");
|
|
const map = embeddedSourceMap();
|
|
for (let index = 0; index < 33; index += 1)
|
|
await writeFile(join(root, `m${String(index)}.js.map`), map);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps.embedded).toBe(32);
|
|
expect(scanned.source_maps.skipped).toBe(1);
|
|
expect(scanned.text_members.skipped).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader source-map count", () => {
|
|
it.each([
|
|
[
|
|
"dotnet-bundle",
|
|
(bytes: Buffer, paths: readonly string[]): Buffer =>
|
|
dotnetBundleFixture(
|
|
paths.map((path) => ({ path, bytes, compressed: true })),
|
|
).bytes,
|
|
],
|
|
[
|
|
"pyinstaller-carchive",
|
|
(bytes: Buffer, paths: readonly string[]): Buffer =>
|
|
cArchiveExecutable({
|
|
carrier: "pe",
|
|
layout: "2.1",
|
|
pythonVersion: 310,
|
|
members: paths.map((name) => ({
|
|
name,
|
|
typeCode: "x",
|
|
compress: true,
|
|
payload: bytes,
|
|
})),
|
|
}),
|
|
],
|
|
])(
|
|
"charges an oversized %s map to the map count before opening it",
|
|
async (_format, archive) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-over-");
|
|
const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 8;
|
|
const oversized = Buffer.alloc(
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1,
|
|
0x20,
|
|
);
|
|
const paths = Array.from(
|
|
{ length: count },
|
|
(_, index) => `m${String(index)}.js.map`,
|
|
);
|
|
const target = join(root, "app.dll");
|
|
await writeFile(target, archive(oversized, paths));
|
|
let opened = 0;
|
|
createdReaders.wrap = (reader, path) => {
|
|
if (path !== target) return reader;
|
|
const open = reader.open.bind(reader);
|
|
reader.open = (entry, signal) => {
|
|
if (entry.path.endsWith(".js.map")) opened += 1;
|
|
return open(entry, signal);
|
|
};
|
|
return reader;
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(opened).toBe(MAX_RELEASE_EXPOSURE_SOURCE_MAPS);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 0,
|
|
skipped: count,
|
|
});
|
|
expect(scanned.text_members.skipped).toBe(count);
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps a linker map's outcome past the count once oversized maps use it", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-spent-");
|
|
const oversized = Buffer.alloc(
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1,
|
|
0x20,
|
|
);
|
|
oversized.write("Archive member included to satisfy reference\n");
|
|
await writeFile(
|
|
join(root, "app.dll"),
|
|
dotnetBundleFixture(
|
|
Array.from(
|
|
{ length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1 },
|
|
(_, index) => ({
|
|
path: `m${String(index)}.map`,
|
|
bytes: oversized,
|
|
compressed: true,
|
|
}),
|
|
),
|
|
).bytes,
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.debug_companions.paths).toHaveLength(
|
|
MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1,
|
|
);
|
|
expect(scanned.source_maps.skipped).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader sibling map allowance", () => {
|
|
const countSiblingReads = async (
|
|
run: () => Promise<ReleaseExposureScan>,
|
|
): Promise<{ scanned: ReleaseExposureScan; budgets: number[] }> => {
|
|
const budgets: number[] = [];
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
if (real === undefined) throw new Error("stable read is not installed");
|
|
if (args[0].endsWith(".map")) budgets.push(args[1]);
|
|
return real(...args);
|
|
};
|
|
try {
|
|
return { scanned: await run(), budgets };
|
|
} finally {
|
|
stableRead.impl = real;
|
|
}
|
|
};
|
|
|
|
it("stops full sibling map reads at the map count", async () => {
|
|
const root = await createTestTempDirectory("rea-release-sibling-budget-");
|
|
const extra = 3;
|
|
const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + extra;
|
|
await Promise.all(
|
|
Array.from({ length: count }, (_, index) =>
|
|
writeFile(
|
|
join(root, `s${String(index)}.map`),
|
|
JSON.stringify({ version: 3, sources: [], mappings: "" }),
|
|
),
|
|
),
|
|
);
|
|
await writeFile(join(root, "app"), "plain file");
|
|
const { scanned, budgets } = await countSiblingReads(() =>
|
|
inspect(join(root, "app")),
|
|
);
|
|
expect(budgets).toHaveLength(count);
|
|
expect(
|
|
budgets.filter(
|
|
(bytes) => bytes === MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES,
|
|
),
|
|
).toHaveLength(MAX_RELEASE_EXPOSURE_SOURCE_MAPS);
|
|
expect(
|
|
budgets.filter(
|
|
(bytes) => bytes === MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
|
|
),
|
|
).toHaveLength(extra);
|
|
expect(scanned.debug_companions).toEqual({ paths: [], skipped: extra });
|
|
expect(scanned.source_maps).toMatchObject({ skipped: 0, samples: [] });
|
|
});
|
|
|
|
it("settles sibling maps from the probe once the selected file spends the count", async () => {
|
|
const root = await createTestTempDirectory("rea-release-sibling-spent-");
|
|
const map = Buffer.from(
|
|
JSON.stringify({ version: 3, sources: [], mappings: "" }),
|
|
);
|
|
await writeFile(
|
|
join(root, "app.dll"),
|
|
dotnetBundleFixture(
|
|
Array.from(
|
|
{ length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS },
|
|
(_, index) => ({
|
|
path: `m${String(index)}.js.map`,
|
|
bytes: map,
|
|
compressed: true,
|
|
}),
|
|
),
|
|
).bytes,
|
|
);
|
|
const pastProbe = MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES + 1;
|
|
const linker = "Archive member included to satisfy reference\n";
|
|
await writeFile(join(root, "short-linker.map"), linker);
|
|
await writeFile(
|
|
join(root, "long-linker.map"),
|
|
linker.padEnd(pastProbe, " "),
|
|
);
|
|
await writeFile(join(root, "short-json.map"), map);
|
|
await writeFile(join(root, "long-json.map"), "{".padEnd(pastProbe, " "));
|
|
const { scanned, budgets } = await countSiblingReads(() =>
|
|
inspect(join(root, "app.dll")),
|
|
);
|
|
expect(budgets).toEqual(
|
|
Array.from({ length: 4 }, () => MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES),
|
|
);
|
|
expect([...scanned.debug_companions.paths].sort()).toEqual([
|
|
join(root, "long-linker.map"),
|
|
join(root, "short-linker.map"),
|
|
]);
|
|
expect(scanned.debug_companions.skipped).toBe(2);
|
|
expect(scanned.source_maps.skipped).toBe(0);
|
|
});
|
|
});
|
|
|
|
const bundled = async (
|
|
files: readonly { path: string; bytes: Buffer }[],
|
|
): Promise<string> => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-bundle-");
|
|
await writeFile(join(root, "app.dll"), dotnetBundleFixture(files).bytes);
|
|
return root;
|
|
};
|
|
|
|
describe("ReleaseExposureReader text encodings", () => {
|
|
const marker = "-----BEGIN PRIVATE KEY-----\n";
|
|
const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
sources: ["src/app.ts"],
|
|
sourcesContent: ["export {};"],
|
|
mappings: "",
|
|
}),
|
|
).toString("base64")}`;
|
|
|
|
const utf16 = (text: string, order: "le" | "be"): Buffer => {
|
|
const little = Buffer.from(`\ufeff${text}`, "utf16le");
|
|
return order === "le" ? little : Buffer.from(little).swap16();
|
|
};
|
|
|
|
const visited = async (path: string) => {
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const texts: string[] = [];
|
|
const result = await reader.inspect({ path }, undefined, (text) => {
|
|
texts.push(text);
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
return { texts, members: result.value.text_members };
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
};
|
|
|
|
it.each(["le", "be"] as const)(
|
|
"decodes UTF-16 %s members instead of scanning NUL-interleaved bytes",
|
|
async (order) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
const plist = `<?xml version="1.0" encoding="UTF-16"?><plist><string>${marker}</string></plist>`;
|
|
await writeFile(join(root, "Info.plist"), utf16(plist, order));
|
|
await writeFile(join(root, "credentials"), utf16(marker, order));
|
|
const { texts, members } = await visited(root);
|
|
expect(texts.sort()).toEqual([marker, plist].sort());
|
|
expect(members).toEqual({ scanned: 2, skipped: 0 });
|
|
},
|
|
);
|
|
|
|
it("counts wide text it cannot decode as skipped, whatever the member declares", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
const wide = Buffer.from(marker, "utf16le");
|
|
await writeFile(join(root, "bomless.plist"), wide);
|
|
await writeFile(join(root, "bomless.js"), wide);
|
|
await writeFile(
|
|
join(root, "wide.plist"),
|
|
Buffer.concat([
|
|
Buffer.from([0xff, 0xfe, 0, 0]),
|
|
Buffer.from(
|
|
[...marker].flatMap((char) => [char.charCodeAt(0), 0, 0, 0]),
|
|
),
|
|
]),
|
|
);
|
|
const { texts, members } = await visited(root);
|
|
expect(texts).toEqual([]);
|
|
expect(members).toEqual({ scanned: 0, skipped: 3 });
|
|
});
|
|
|
|
it("scans UTF-8 that holds a NUL, with its inline maps", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
const script = `const sep = "\0";\nconst key = \`${marker}\`;\n${inlineMap}\n`;
|
|
await writeFile(join(root, "app.js"), script);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect((await visited(root)).texts).toEqual([script]);
|
|
});
|
|
|
|
it("does not read NUL-bearing bytes that are not UTF-8 as complete text", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(
|
|
join(root, "app.js"),
|
|
Buffer.concat([
|
|
Buffer.from(`const sep = "\0";\n`),
|
|
Buffer.from([0xff]),
|
|
Buffer.alloc(64, 0x20),
|
|
]),
|
|
);
|
|
const { texts, members } = await visited(root);
|
|
expect(texts).toEqual([]);
|
|
expect(members).toEqual({ scanned: 0, skipped: 1 });
|
|
});
|
|
|
|
/** One dictionary whose value objects are the given encoded objects. */
|
|
const binaryPlist = (key: string, value: Buffer): Buffer => {
|
|
const objects = [
|
|
Buffer.from([0xd1, 1, 2]),
|
|
Buffer.concat([Buffer.from([0x50 | key.length]), Buffer.from(key)]),
|
|
value,
|
|
];
|
|
const table =
|
|
8 + objects.reduce((length, object) => length + object.length, 0);
|
|
const bytes = Buffer.alloc(table + objects.length + 32);
|
|
let cursor = 8;
|
|
const offsets = objects.map((object) => {
|
|
const offset = cursor;
|
|
object.copy(bytes, cursor);
|
|
cursor += object.length;
|
|
return offset;
|
|
});
|
|
return binaryPlistObjectTable(bytes, offsets, table);
|
|
};
|
|
/** Length-prefixed object body; every test value here exceeds the short form. */
|
|
const plistObject = (type: number, length: number, body: Buffer): Buffer =>
|
|
Buffer.concat([Buffer.from([(type << 4) | 0x0f, 0x10, length]), body]);
|
|
|
|
it.each([
|
|
["ASCII", plistObject(5, marker.length, Buffer.from(marker))],
|
|
[
|
|
"UTF-16BE",
|
|
plistObject(
|
|
6,
|
|
`clé: ${marker}`.length,
|
|
utf16(`clé: ${marker}`, "be").subarray(2),
|
|
),
|
|
],
|
|
["data", plistObject(4, marker.length, Buffer.from(marker))],
|
|
])("scans a binary plist's %s value", async (_encoding, value) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(join(root, "Info.plist"), binaryPlist("key", value));
|
|
const { texts, members } = await visited(root);
|
|
expect(texts.join("")).toContain(marker);
|
|
expect(members).toEqual({ scanned: 1, skipped: 0 });
|
|
});
|
|
|
|
it("keeps an undecodable binary plist's bytes in the scan, unassessed", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
const bytes = Buffer.concat([
|
|
Buffer.from("bplist00"),
|
|
Buffer.from([0]),
|
|
Buffer.from(marker),
|
|
]);
|
|
await writeFile(join(root, "Info.plist"), bytes);
|
|
const { texts, members } = await visited(root);
|
|
expect(texts.join("")).toContain(marker);
|
|
expect(members).toEqual({ scanned: 0, skipped: 1 });
|
|
});
|
|
|
|
it("reads an inline map from a UTF-16 script", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(
|
|
join(root, "app.js"),
|
|
utf16(`console.log(1);\n${inlineMap}\n`, "le"),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader text recognition", () => {
|
|
const marker = "-----BEGIN PRIVATE KEY-----\n";
|
|
|
|
it("scans a text file whose name is outside the extension list", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(join(root, ".env.production"), marker);
|
|
await writeFile(join(root, "server.key"), marker);
|
|
await writeFile(join(root, "credentials"), marker);
|
|
await writeFile(
|
|
join(root, "image.dat"),
|
|
Buffer.concat([Buffer.from("PNG"), Buffer.alloc(16)]),
|
|
);
|
|
await writeFile(join(root, "empty"), "");
|
|
const scanned = await inspect(root);
|
|
expect(scanned.textLocations.sort()).toEqual([
|
|
join(root, ".env.production"),
|
|
join(root, "credentials"),
|
|
join(root, "server.key"),
|
|
]);
|
|
expect(scanned.text_members).toEqual({ scanned: 3, skipped: 0 });
|
|
});
|
|
|
|
it("scans text past the probe prefix and counts text past the member allowance", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(
|
|
join(root, "long"),
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES * 2, 0x20),
|
|
);
|
|
await writeFile(
|
|
join(root, "oversized"),
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.textLocations).toEqual([join(root, "long")]);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 1 });
|
|
});
|
|
|
|
it("recognizes a package member by content", async () => {
|
|
const root = await bundled([
|
|
{ path: "credentials", bytes: Buffer.from(marker) },
|
|
{ path: "payload", bytes: Buffer.alloc(32) },
|
|
]);
|
|
const scanned = await inspect(root);
|
|
const host = join(root, "app.dll");
|
|
expect(scanned.textLocations).toContain(`${host}/credentials`);
|
|
expect(scanned.textLocations).not.toContain(`${host}/payload`);
|
|
});
|
|
|
|
it("leaves text undecided when a file could not be probed", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
const opaque = join(root, "credentials");
|
|
await writeFile(opaque, marker);
|
|
const reader = new ReleaseExposureReader();
|
|
let denied = 0;
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
const [path] = args;
|
|
if (path !== opaque && real !== undefined) return real(...args);
|
|
denied += 1;
|
|
throw Object.assign(new Error("denied"), { code: "EACCES" });
|
|
};
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(denied).toBe(1);
|
|
expect(result.value.text_members.skipped).toBe(1);
|
|
} finally {
|
|
stableRead.impl = real;
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader source spellings", () => {
|
|
const decomposed = "caf\u0065\u0301";
|
|
const key = "-----BEGIN PRIVATE KEY-----\n";
|
|
|
|
it("reopens a decomposed ASAR member through its source spelling", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([
|
|
{ name: `${decomposed}.js.map`, bytes: embeddedSourceMap() },
|
|
{ name: `${decomposed}.txt`, bytes: Buffer.from(key) },
|
|
]),
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
expect(scanned.text_members.scanned).toBeGreaterThan(0);
|
|
expect(scanned.text_members.skipped).toBe(0);
|
|
expect(scanned.extractable).toMatchObject({ unreadable_members: 0 });
|
|
});
|
|
|
|
it("follows a percent-encoded decomposed map name in an ASAR", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-nfd-map-");
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([
|
|
{
|
|
name: "app.js",
|
|
bytes: Buffer.from(
|
|
"const value = 1;\n//# sourceMappingURL=cafe%CC%81.json\n",
|
|
),
|
|
},
|
|
{ name: `${decomposed}.json`, bytes: embeddedSourceMap() },
|
|
]),
|
|
);
|
|
const scanned = await inspect(join(root, "app.asar"));
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect(scanned.source_maps.embedded_paths).toHaveLength(1);
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("keeps a nested ASAR that was not expanded unknown", async () => {
|
|
const root = await createTestTempDirectory("rea-release-nested-asar-");
|
|
const inner = asarArchive([
|
|
{ name: "app.js.map", bytes: embeddedSourceMap() },
|
|
]);
|
|
await writeFile(
|
|
join(root, "app.asar"),
|
|
asarArchive([{ name: "inner.asar", bytes: inner }]),
|
|
);
|
|
const scanned = await inspect(join(root, "app.asar"));
|
|
expect(scanned.source_maps.embedded).toBe(0);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
|
|
expect(scanned.extractable.readable_members).toBe(1);
|
|
expect(scanned.extractable.samples).toEqual([
|
|
expect.stringContaining("inner.asar"),
|
|
]);
|
|
});
|
|
|
|
it("reopens a decomposed compressed .NET member", async () => {
|
|
const stored = deflateRawSync(Buffer.from(key));
|
|
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
|
|
await writeFile(
|
|
join(root, "app.dll"),
|
|
dotnetBundleFixture([
|
|
{
|
|
path: `${decomposed}.txt`,
|
|
bytes: Buffer.from(key),
|
|
compressed: true,
|
|
storedBytes: stored,
|
|
},
|
|
]).bytes,
|
|
);
|
|
const scanned = await inspect(join(root, "app.dll"));
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
expect(scanned.extractable).toMatchObject({
|
|
readable_members: 1,
|
|
unreadable_members: 0,
|
|
});
|
|
});
|
|
|
|
it("finds a compressed member whose source name differs from its path", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-src-");
|
|
const file = join(root, "app.exe");
|
|
await writeFile(
|
|
file,
|
|
cArchiveExecutable({
|
|
carrier: "pe",
|
|
layout: "2.1",
|
|
pythonVersion: 310,
|
|
members: [
|
|
{
|
|
name: "docs\\notes.txt",
|
|
typeCode: "x",
|
|
compress: true,
|
|
payload: Buffer.from(key),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const scanned = await inspect(file);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
expect(scanned.extractable).toMatchObject({
|
|
readable_members: 1,
|
|
unreadable_members: 0,
|
|
});
|
|
});
|
|
|
|
it("reads a decomposed directory member by its source spelling", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
|
|
await mkdir(join(root, "nested"));
|
|
const name = join(root, "nested", `${decomposed}.txt`);
|
|
await writeFile(name, key);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.textLocations).toEqual([name]);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
});
|
|
|
|
it("keeps symlink traversal in a selected directory path", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-link-");
|
|
await mkdir(join(root, "real", "child"), { recursive: true });
|
|
await writeFile(join(root, "real", "notes.txt"), key);
|
|
await writeFile(join(root, "notes.txt"), "decoy\n");
|
|
await symlink(join(root, "real", "child"), join(root, "link"));
|
|
const selected = `${root}/link/..`;
|
|
const scanned = await inspect(selected);
|
|
expect(scanned.textLocations).toEqual([`${selected}/notes.txt`]);
|
|
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader package traversal coverage", () => {
|
|
it("keeps a package reader's unknown coverage for an unexpanded member", async () => {
|
|
const root = await bundled([
|
|
{ path: "notes.txt", bytes: Buffer.from("plain\n") },
|
|
]);
|
|
const opaque = "An opaque archive was not expanded.";
|
|
const actual = await vi.importActual<
|
|
typeof import("./inventory/ArtifactInventory.js")
|
|
>("./inventory/ArtifactInventory.js");
|
|
let inventoried = false;
|
|
inventoryRead.impl = async (...args) => {
|
|
const inventory = await actual.inventoryArtifact(...args);
|
|
inventoried = true;
|
|
return inventory;
|
|
};
|
|
let reported = 0;
|
|
createdReaders.wrap = (source) => {
|
|
const entries = source.entries.bind(source);
|
|
source.entries = (signal, options) => {
|
|
// Inventory finished, so only this audit's own listing sees the gap.
|
|
if (inventoried) {
|
|
reported += 1;
|
|
options?.onCoverage?.({ status: "unknown", limitations: [opaque] });
|
|
}
|
|
return entries(signal, options);
|
|
};
|
|
return source;
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(reported).toBeGreaterThan(0);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(scanned.limitations).toContain(opaque);
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader unexpanded packages", () => {
|
|
it("does not count an oversized binary entry as a text member", async () => {
|
|
const root = await bundled([
|
|
{
|
|
path: "Large.bin",
|
|
bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1),
|
|
},
|
|
]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.text_members.skipped).toBe(0);
|
|
expect(scanned.omitted.map((item) => item.subject)).not.toContain(
|
|
"text members",
|
|
);
|
|
});
|
|
|
|
it("reports members skipped by an allowance as readable, not unreadable", async () => {
|
|
const root = await bundled([
|
|
{
|
|
path: "Large.bin",
|
|
bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1),
|
|
},
|
|
{
|
|
path: "large.js",
|
|
bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
},
|
|
]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.extractable).toMatchObject({
|
|
readable_members: 2,
|
|
unreadable_members: 0,
|
|
});
|
|
expect(scanned.text_members.skipped).toBe(1);
|
|
});
|
|
|
|
it("names the host file on every member location", async () => {
|
|
const root = await bundled([
|
|
{ path: "notes.txt", bytes: Buffer.from("plain\n") },
|
|
{ path: "app.js.map", bytes: embeddedSourceMap() },
|
|
]);
|
|
const scanned = await inspect(root);
|
|
const host = join(root, "app.dll");
|
|
expect(scanned.textLocations).toContain(`${host}/notes.txt`);
|
|
expect(scanned.source_maps.embedded_paths).toEqual([`${host}/app.js.map`]);
|
|
expect(scanned.extractable.samples).toContain(`${host}/notes.txt`);
|
|
expect(scanned.native_images.map((image) => image.path)).toEqual([host]);
|
|
});
|
|
|
|
it("reports a debug companion stored in the package under its host", async () => {
|
|
const root = await bundled([{ path: "App.pdb", bytes: Buffer.from("x") }]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.debug_companions.paths).toEqual([
|
|
`${join(root, "app.dll")}/App.pdb`,
|
|
]);
|
|
});
|
|
|
|
it("leaves a PE too large to read undecided instead of absent", async () => {
|
|
const image = Buffer.concat([
|
|
pe(0x8664, 64, 0x0002, false, { pdbPath: "C:\\build\\app.pdb" }),
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES),
|
|
]);
|
|
const root = await bundled([{ path: "Large.dll", bytes: image }]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.pdb.failed).toBe(1);
|
|
expect(scanned.pdb.paths).toEqual([]);
|
|
expect(scanned.limitations.join("\n")).toContain("Large.dll");
|
|
expect(scanned.extractable.unreadable_members).toBe(0);
|
|
});
|
|
|
|
it("reads CodeView paths from a small packaged PE without charging text", async () => {
|
|
const root = await bundled([
|
|
{
|
|
path: "Small.dll",
|
|
bytes: pe(0x8664, 64, 0x0002, false, {
|
|
pdbPath: "C:\\build\\small.pdb",
|
|
}),
|
|
},
|
|
]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.pdb.paths).toContain("C:\\build\\small.pdb");
|
|
expect(scanned.pdb.image_paths).toEqual([
|
|
`${join(root, "app.dll")}/Small.dll`,
|
|
]);
|
|
expect(scanned.text_members.skipped).toBe(0);
|
|
});
|
|
|
|
it("keeps a packaged PE header beyond the probe unresolved", async () => {
|
|
const original = pe(0x8664, 64, 0x0002, false, {
|
|
pdbPath: "C:\\build\\delayed.pdb",
|
|
});
|
|
const image = Buffer.alloc(8192 + 512);
|
|
original.copy(image);
|
|
original.subarray(64, 512).copy(image, 8192);
|
|
image.writeUInt32LE(8192, 60);
|
|
const root = await bundled([{ path: "Delayed.dll", bytes: image }]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.pdb).toMatchObject({ failed: 1, paths: [] });
|
|
expect(scanned.limitations.join("\n")).toContain("Delayed.dll");
|
|
expect(scanned.omitted).toContainEqual(
|
|
expect.objectContaining({ subject: PACKAGED_NATIVE_IMAGES, count: 1 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader unexpanded package PE reads", () => {
|
|
it("retains each compressed duplicate entry across PE reads", async () => {
|
|
const root = await createTestTempDirectory("rea-release-duplicate-pe-");
|
|
const paths = ["C:\\build\\first.pdb", "C:\\build\\second.pdb"];
|
|
const file = join(root, "app");
|
|
await writeFile(
|
|
file,
|
|
cArchiveExecutable({
|
|
layout: "2.1",
|
|
pythonVersion: 310,
|
|
members: paths.map((pdbPath) => ({
|
|
name: "same.dll",
|
|
typeCode: "b",
|
|
compress: true,
|
|
payload: Buffer.concat([
|
|
pe(0x8664, 64, 0x0002, false, { pdbPath }),
|
|
Buffer.alloc(4096),
|
|
]),
|
|
})),
|
|
}),
|
|
);
|
|
const scanned = await inspect(file);
|
|
expect(scanned.pdb.paths).toEqual(paths);
|
|
expect(scanned.pdb).toMatchObject({ examined: 2, failed: 0 });
|
|
expect(scanned.extractable).toMatchObject({
|
|
readable_members: 2,
|
|
unreadable_members: 0,
|
|
});
|
|
});
|
|
|
|
it("omits a packaged native image from the language readers visibly", async () => {
|
|
const elf = Buffer.alloc(64);
|
|
elf.set([0x7f, 0x45, 0x4c, 0x46], 0);
|
|
const root = await bundled([{ path: "libnative.so", bytes: elf }]);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.omitted).toContainEqual(
|
|
expect.objectContaining({
|
|
subject: PACKAGED_NATIVE_IMAGES,
|
|
count: 1,
|
|
}),
|
|
);
|
|
expect(scanned.native_images.map((image) => image.path)).toEqual([
|
|
join(root, "app.dll"),
|
|
]);
|
|
});
|
|
|
|
it("leaves member coverage unknown when a package cannot be listed", async () => {
|
|
const root = await bundled([{ path: "app.js", bytes: Buffer.from("1") }]);
|
|
createdReaders.wrap = (reader, path) => {
|
|
if (path.endsWith("app.dll"))
|
|
reader.entries = async function* () {
|
|
yield* [];
|
|
throw new Error("listing failed");
|
|
};
|
|
return reader;
|
|
};
|
|
try {
|
|
const scanned = await inspect(root);
|
|
expect(scanned.coverage).toBe("unknown");
|
|
expect(scanned.extractable.unreadable_members).toBeGreaterThan(0);
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader probed FAT identity", () => {
|
|
const omittedNative = async (bytes: Buffer): Promise<number | undefined> => {
|
|
const root = await bundled([{ path: "member.bin", bytes }]);
|
|
const scanned = await inspect(root);
|
|
return scanned.omitted.find(
|
|
(item) => item.subject === PACKAGED_NATIVE_IMAGES,
|
|
)?.count;
|
|
};
|
|
|
|
it("keeps a JVM class past the probe out of the packaged native images", async () => {
|
|
const bytes = javaClassFile(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES);
|
|
expect(bytes.length).toBeGreaterThan(
|
|
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
|
|
);
|
|
await expect(omittedNative(bytes)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("counts a universal image whose slice headers sit past the probe", async () => {
|
|
const slice = Buffer.alloc(64);
|
|
slice.writeUInt32BE(0xcffaedfe, 0);
|
|
const { bytes, slices } = universalMacho([
|
|
{ bytes: slice, cpuType: 0x0100000c, cpuSubtype: 0 },
|
|
]);
|
|
expect(slices[0]?.offset).toBeGreaterThanOrEqual(
|
|
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
|
|
);
|
|
await expect(omittedNative(bytes)).resolves.toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader bounded listing and cleanup", () => {
|
|
it("reports sibling close failure with the completed scan and retains its owner", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-release-exposure-sibling-close-",
|
|
);
|
|
const selected = join(root, "app.js");
|
|
const companion = join(root, "app.pdb");
|
|
await writeFile(selected, "const value = 1;");
|
|
await writeFile(companion, "debug");
|
|
const reader = new ReleaseExposureReader();
|
|
directoryReads.closeHandles = [];
|
|
directoryReads.failClose = true;
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: selected },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("Expected a failed audit.");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources).toContain(
|
|
`directory beside the selected file: ${root}`,
|
|
);
|
|
expect(result.error.diagnostics?.primary_error).toMatchObject({
|
|
details: {
|
|
diagnostics: { scan: { debug_companions: { paths: [companion] } } },
|
|
},
|
|
});
|
|
const handle = directoryReads.failedHandle;
|
|
expect(handle).toBeDefined();
|
|
await expect(reader.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
directoryReads.failClose = false;
|
|
await expect(reader.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
// A failed Dir.close has no validity signal authorizing another native close.
|
|
expect(directoryReads.closeHandles).toEqual([handle]);
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
} finally {
|
|
directoryReads.failClose = false;
|
|
directoryReads.failedHandle = undefined;
|
|
directoryReads.closeHandles = [];
|
|
await reader.close().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("applies the caller's byte ceiling to on-disk PE reads", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-ceiling-");
|
|
await writeFile(
|
|
join(root, "app.exe"),
|
|
pe(0x8664, 64, 0x0002, false, { pdbPath: "C:\\build\\app.pdb" }),
|
|
);
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root, max_file_bytes: 64 },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.pdb).toMatchObject({ failed: 1, paths: [] });
|
|
expect(result.value.coverage).toBe("unknown");
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("stops reading a large parent directory at the sibling allowance", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-listing-");
|
|
await Promise.all(
|
|
Array.from({ length: 4100 }, (_, index) =>
|
|
writeFile(join(root, `f${String(index)}.dat`), ""),
|
|
),
|
|
);
|
|
await writeFile(join(root, "app"), "selected");
|
|
directoryReads.count = 0;
|
|
const scanned = await inspect(join(root, "app"));
|
|
expect(directoryReads.count).toBeLessThanOrEqual(4097);
|
|
expect(scanned.debug_companions.skipped).toBe(1);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "an embedded-package probe",
|
|
selected: "lib.so",
|
|
write: async (root: string) => {
|
|
const elf = Buffer.alloc(64);
|
|
elf.set([0x7f, 0x45, 0x4c, 0x46], 0);
|
|
await writeFile(join(root, "lib.so"), elf);
|
|
},
|
|
},
|
|
{
|
|
name: "reading an oversized map prefix",
|
|
selected: "bundle.map",
|
|
write: async (root: string) => {
|
|
await writeFile(
|
|
join(root, "bundle.map"),
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, 0x20),
|
|
);
|
|
},
|
|
},
|
|
])(
|
|
"reports and retains a descriptor whose close failed after $name",
|
|
async ({ selected, write }) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-fd-");
|
|
await write(root);
|
|
const actual = await vi.importActual<
|
|
typeof import("./inventory/ArtifactInventory.js")
|
|
>("./inventory/ArtifactInventory.js");
|
|
inventoryRead.impl = async (...args) => {
|
|
const inventory = await actual.inventoryArtifact(...args);
|
|
regularFiles.failClose = (path) => path.endsWith(selected);
|
|
return inventory;
|
|
};
|
|
regularFiles.closes = 0;
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("expected cleanup-incomplete failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources.join("\n")).toContain(selected);
|
|
regularFiles.failUntil = regularFiles.closes + 1;
|
|
await expect(reader.close()).rejects.toSatisfy(
|
|
(error: unknown) =>
|
|
(error as { cleanupResources?: readonly string[] }).cleanupResources
|
|
?.join("\n")
|
|
.includes(selected) === true,
|
|
);
|
|
await reader.close();
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
regularFiles.failClose = undefined;
|
|
regularFiles.failUntil = 1;
|
|
await reader.close().catch(() => undefined);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("ReleaseExposureReader cleanup-bearing read failures", () => {
|
|
const cleanupFailure = (
|
|
reason: "limit" | "format",
|
|
resource: string,
|
|
): ArtifactReaderFailure =>
|
|
ArtifactReaderFailure.withCleanup(
|
|
new ArtifactReaderFailure(reason, `${reason} failure: ${resource}`),
|
|
{ reason: "close failed", resources: [resource] },
|
|
);
|
|
|
|
it("does not read a cleanup-incomplete limit failure as an oversized member", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-limit-");
|
|
await writeFile(join(root, "bundle.map"), "{}");
|
|
const real = stableRead.impl;
|
|
stableRead.impl = async (...args) => {
|
|
if (args[0].endsWith("bundle.map"))
|
|
throw cleanupFailure("limit", args[0]);
|
|
return (real as NonNullable<typeof real>)(...args);
|
|
};
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: root },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("expected cleanup-incomplete failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources.join("\n")).toContain("bundle.map");
|
|
} finally {
|
|
stableRead.impl = real;
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("does not read cleanup-incomplete inventory as unknown coverage", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-inv-");
|
|
const selected = join(root, "app");
|
|
await writeFile(selected, "selected");
|
|
inventoryRead.impl = () =>
|
|
Promise.reject(cleanupFailure("format", selected));
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const result = await reader.inspect(
|
|
{ path: selected },
|
|
undefined,
|
|
ignoreText,
|
|
);
|
|
if (result.ok) throw new Error("expected cleanup-incomplete failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources).toContain(selected);
|
|
} finally {
|
|
inventoryRead.impl = undefined;
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
const embeddedSourceMap = (): Buffer =>
|
|
Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
file: "app.js",
|
|
sources: ["app.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: [`export const secret = "${PLANTED_SOURCE}";\n`],
|
|
}),
|
|
);
|
|
|
|
const asarArchive = (
|
|
files: readonly { readonly name: string; readonly bytes: Buffer }[],
|
|
): Buffer => {
|
|
let offset = 0;
|
|
const table: Record<string, { size: number; offset: string }> = {};
|
|
for (const { name, bytes } of files) {
|
|
table[name] = { size: bytes.length, offset: String(offset) };
|
|
offset += bytes.length;
|
|
}
|
|
return Buffer.concat([
|
|
asarHeaderBytes(JSON.stringify({ files: table })),
|
|
...files.map(({ bytes }) => bytes),
|
|
]);
|
|
};
|
|
|
|
const sourceMapArchive = (): Buffer => {
|
|
const map = embeddedSourceMap();
|
|
const notes = Buffer.from(
|
|
`-----BEGIN PRIVATE KEY-----\n${PLANTED_KEY}\n-----END PRIVATE KEY-----\n`,
|
|
);
|
|
return Buffer.concat([
|
|
asarHeaderBytes(
|
|
JSON.stringify({
|
|
files: {
|
|
dist: {
|
|
files: {
|
|
"app.js.map": { size: map.length, offset: "0" },
|
|
"notes.txt": { size: notes.length, offset: String(map.length) },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
map,
|
|
notes,
|
|
]);
|
|
};
|
|
|
|
describe("ReleaseExposureReader same-name members", () => {
|
|
it("scans every same-name package member instead of the last one twice", async () => {
|
|
const root = await createTestTempDirectory("rea-release-same-name-");
|
|
const archive = join(root, "app.asar");
|
|
await writeFile(
|
|
archive,
|
|
asarArchive([{ name: "config.txt", bytes: Buffer.from("x") }]),
|
|
);
|
|
const contents = new Map([
|
|
["first", "marker"],
|
|
["second", "harmless"],
|
|
]);
|
|
createdReaders.wrap = (source) => {
|
|
const list = source.entries.bind(source);
|
|
source.entries = async function* (signal) {
|
|
for await (const entry of list(signal)) {
|
|
if (entry.path !== "config.txt") {
|
|
yield entry;
|
|
continue;
|
|
}
|
|
for (const adapterKey of contents.keys())
|
|
yield { ...entry, adapterKey };
|
|
}
|
|
};
|
|
source.open = (entry) =>
|
|
Promise.resolve(Readable.from([contents.get(entry.adapterKey) ?? ""]));
|
|
return source;
|
|
};
|
|
try {
|
|
const reader = new ReleaseExposureReader();
|
|
const texts: string[] = [];
|
|
const result = await reader.inspect(
|
|
{ path: archive },
|
|
undefined,
|
|
(text) => {
|
|
texts.push(text);
|
|
},
|
|
);
|
|
await reader.close();
|
|
if (!result.ok) throw result.error;
|
|
expect(texts).toEqual(expect.arrayContaining(["harmless", "marker"]));
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader member identity", () => {
|
|
it("reads a compressed same-name member after an empty stored one", async () => {
|
|
const root = await createTestTempDirectory("rea-release-empty-first-");
|
|
const file = join(root, "app");
|
|
await writeFile(
|
|
file,
|
|
cArchiveExecutable({
|
|
layout: "2.1",
|
|
pythonVersion: 310,
|
|
members: [
|
|
{
|
|
name: "config.txt",
|
|
typeCode: "x",
|
|
compress: false,
|
|
payload: Buffer.alloc(0),
|
|
},
|
|
{
|
|
name: "config.txt",
|
|
typeCode: "x",
|
|
compress: true,
|
|
payload: Buffer.from("credential-marker"),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const reader = new ReleaseExposureReader();
|
|
const texts: string[] = [];
|
|
const result = await reader.inspect({ path: file }, undefined, (text) => {
|
|
texts.push(text);
|
|
});
|
|
await reader.close();
|
|
if (!result.ok) throw result.error;
|
|
expect(texts).toContain("credential-marker");
|
|
});
|
|
|
|
it.each([
|
|
["app.js", "debug.json", { embedded: 1, skipped: 0 }],
|
|
["z.js", "a.json", { embedded: 0, skipped: 1 }],
|
|
])(
|
|
"follows %s to a differently named local map %s",
|
|
async (script, map, expected) => {
|
|
const root = await createTestTempDirectory("rea-release-named-map-");
|
|
await writeFile(
|
|
join(root, script),
|
|
`const value = 1;\n//# sourceMappingURL=${map}\n`,
|
|
);
|
|
await writeFile(join(root, map), embeddedSourceMap());
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject(expected);
|
|
},
|
|
);
|
|
|
|
it("counts a declared map target that never ships as skipped", async () => {
|
|
const root = await createTestTempDirectory("rea-release-missing-map-");
|
|
await writeFile(
|
|
join(root, "app.js"),
|
|
"const value = 1;\n//# sourceMappingURL=debug.json\n",
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 });
|
|
});
|
|
|
|
it.each([
|
|
["app.js", "debug.json", { embedded: 1, skipped: 0 }],
|
|
["z.js", "a.json", { embedded: 0, skipped: 1 }],
|
|
])(
|
|
"follows %s through a file: URL to the inventoried member %s",
|
|
async (script, map, expected) => {
|
|
const root = await createTestTempDirectory("rea-release-file-url-map-");
|
|
const url = pathToFileURL(join(root, map)).href;
|
|
await writeFile(
|
|
join(root, script),
|
|
`const value = 1;\n//# sourceMappingURL=${url}\n`,
|
|
);
|
|
await writeFile(join(root, map), embeddedSourceMap());
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject(expected);
|
|
},
|
|
);
|
|
|
|
it.each(["debug.json", "debug.js.map"])(
|
|
"counts a file: URL to %s outside the installation as skipped",
|
|
async (map) => {
|
|
const root = await createTestTempDirectory("rea-release-file-url-gone-");
|
|
const outside = await createTestTempDirectory(
|
|
"rea-release-file-url-out-",
|
|
);
|
|
const url = pathToFileURL(join(outside, map)).href;
|
|
await writeFile(
|
|
join(root, "app.js"),
|
|
`const value = 1;\n//# sourceMappingURL=${url}\n`,
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 });
|
|
},
|
|
);
|
|
|
|
it("counts a malformed file: URL as skipped and ignores a remote map", async () => {
|
|
const root = await createTestTempDirectory("rea-release-file-url-bad-");
|
|
await writeFile(
|
|
join(root, "bad.js"),
|
|
"const value = 1;\n//# sourceMappingURL=file:///release%2Fdebug.json\n",
|
|
);
|
|
await writeFile(
|
|
join(root, "remote.js"),
|
|
"const value = 2;\n//# sourceMappingURL=https://example.com/debug.json\n",
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 });
|
|
});
|
|
});
|
|
|
|
const sourceMapSourcesStatus = (scan: ReleaseExposureScan) =>
|
|
assembleReleaseExposureReport(scan, {
|
|
electron_family: "not_electron",
|
|
fuse_images: [],
|
|
go_images: [],
|
|
rust_images: [],
|
|
credential_markers: { hits: [], omitted_hits: 0 },
|
|
}).checks.find((check) => check.id === "javascript.source_map_sources")
|
|
?.status;
|
|
|
|
const inspect = async (path: string) => {
|
|
const reader = new ReleaseExposureReader();
|
|
try {
|
|
const textLocations: string[] = [];
|
|
const result = await reader.inspect({ path }, undefined, (_, location) => {
|
|
textLocations.push(location);
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
return { ...result.value, textLocations };
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
};
|
|
|
|
describe("ReleaseExposureReader inline map reads", () => {
|
|
const map = Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
sources: ["src/app.ts"],
|
|
sourcesContent: ["export {};"],
|
|
mappings: "",
|
|
}),
|
|
).toString("base64");
|
|
|
|
const dataUrl = `data:application/json;base64,${map}`;
|
|
|
|
it.each([
|
|
["a classic script", "<script>"],
|
|
["a module script", '<script type="module">'],
|
|
])("reads an inline map in %s of an HTML file", async (_, open) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
await writeFile(
|
|
join(root, "index.html"),
|
|
`<!doctype html>${open}\nrun();\n//# sourceMappingURL=${dataUrl}\n</script>`,
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("reads an inline map in an HTML member of an ASAR", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
const path = join(root, "app.asar");
|
|
await writeFile(
|
|
path,
|
|
asarArchive([
|
|
{
|
|
name: "index.html",
|
|
bytes: Buffer.from(
|
|
`<script>//# sourceMappingURL=${dataUrl}\n</script>`,
|
|
),
|
|
},
|
|
]),
|
|
);
|
|
expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed");
|
|
});
|
|
|
|
it("does not read a map in a data block or a script that loads by URL", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
await writeFile(
|
|
join(root, "index.html"),
|
|
`<script type="application/json">//# sourceMappingURL=${dataUrl}\n</script>` +
|
|
`<script src="app.js">//# sourceMappingURL=${dataUrl}\n</script>`,
|
|
);
|
|
const scanned = await inspect(root);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("not_observed");
|
|
});
|
|
|
|
it("keeps HTML map coverage unknown for scripts it did not read", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
const path = join(root, "index.html");
|
|
await writeFile(
|
|
path,
|
|
"<script>x</script>".repeat(HTML_INLINE_SCRIPT_LOCATION_LIMIT + 2),
|
|
);
|
|
const past = await inspect(path);
|
|
expect(past.source_maps.skipped).toBe(2);
|
|
expect(sourceMapSourcesStatus(past)).toBe("unknown");
|
|
await writeFile(
|
|
path,
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
);
|
|
expect((await inspect(path)).source_maps.skipped).toBe(1);
|
|
});
|
|
|
|
const bunExecutable = (sourcemap: Buffer): Buffer =>
|
|
elfBunSection(
|
|
bunPrefixedPayload(
|
|
bunModuleGraphPayload({
|
|
modules: [
|
|
{
|
|
name: "/$bunfs/root/main.js",
|
|
contents: Buffer.from("run();\n"),
|
|
sourcemap,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
it.each([
|
|
["a selected executable", (root: string) => join(root, "server")],
|
|
["an executable in a directory", (root: string) => root],
|
|
])("reads a Bun sourcemap region in %s", async (_, selected) => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-bun-");
|
|
await writeFile(
|
|
join(root, "server"),
|
|
bunExecutable(Buffer.from(map, "base64")),
|
|
);
|
|
const scanned = await inspect(selected(root));
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, unparsed: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("keeps a binary Bun sourcemap region unresolved instead of a companion", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-bun-");
|
|
await writeFile(
|
|
join(root, "server"),
|
|
bunExecutable(Buffer.from([1, 0, 0, 0, 0xff, 0xfe, 0, 0])),
|
|
);
|
|
const scanned = await inspect(join(root, "server"));
|
|
expect(scanned.source_maps.unparsed).toBe(1);
|
|
expect(scanned.debug_companions.paths).toEqual([]);
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
|
|
});
|
|
|
|
it("reads a percent-escaped base64 inline map", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-map-");
|
|
const escaped = `%${map.charCodeAt(0).toString(16)}${map.slice(1)}`;
|
|
await writeFile(
|
|
join(root, "app.js"),
|
|
`//# sourceMappingURL=data:application/json;base64,${escaped}\n`,
|
|
);
|
|
const { source_maps: maps } = await inspect(root);
|
|
expect(maps.embedded).toBe(1);
|
|
expect(maps.unparsed).toBe(0);
|
|
});
|
|
|
|
it("reads the inline map of an extensionless SEA entry in a directory", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-map-");
|
|
await writeFile(
|
|
join(root, "app"),
|
|
nodeSeaElf({
|
|
codePath: "entry",
|
|
main: `//# sourceMappingURL=data:application/json;base64,${map}\n`,
|
|
}),
|
|
);
|
|
expect((await inspect(root)).source_maps.embedded).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader HTML script references", () => {
|
|
const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
sources: ["src/app.ts"],
|
|
sourcesContent: ["export {};"],
|
|
mappings: "",
|
|
}),
|
|
).toString("base64")}\n`;
|
|
|
|
const directory = async (
|
|
files: Readonly<Record<string, string>>,
|
|
): Promise<string> => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
for (const [name, text] of Object.entries(files))
|
|
await writeFile(join(root, name), text);
|
|
return root;
|
|
};
|
|
|
|
it("reads the inline map of an extensionless script an HTML file loads", async () => {
|
|
const scanned = await inspect(
|
|
await directory({
|
|
"index.html": '<script src="main"></script>',
|
|
main: inlineMap,
|
|
}),
|
|
);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("reads an extensionless script an ASAR's HTML loads", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-html-");
|
|
const path = join(root, "app.asar");
|
|
await writeFile(
|
|
path,
|
|
asarArchive([
|
|
{
|
|
name: "index.html",
|
|
bytes: Buffer.from('<script src="main"></script>'),
|
|
},
|
|
{ name: "main", bytes: Buffer.from(inlineMap) },
|
|
]),
|
|
);
|
|
expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed");
|
|
});
|
|
|
|
it("resolves a script against the document base", async () => {
|
|
const root = await directory({
|
|
"index.html": '<base href="static/"><script src="main?v=1"></script>',
|
|
});
|
|
await mkdir(join(root, "static"));
|
|
await writeFile(join(root, "static", "main"), inlineMap);
|
|
expect(sourceMapSourcesStatus(await inspect(root))).toBe("exposed");
|
|
});
|
|
|
|
it("does not count a script two pages share as unread", async () => {
|
|
const scanned = await inspect(
|
|
await directory({
|
|
"a.html": '<script src="main"></script>',
|
|
main: inlineMap,
|
|
"z.html": '<script src="main"></script>',
|
|
}),
|
|
);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
|
|
});
|
|
|
|
it("still reads a page another page loads as a script as HTML", async () => {
|
|
const scanned = await inspect(
|
|
await directory({
|
|
"a.html": '<script src="z.html"></script>',
|
|
"z.html": `<!doctype html><script>\nrun();\n${inlineMap}</script>`,
|
|
}),
|
|
);
|
|
expect(scanned.source_maps.embedded).toBe(1);
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("exposed");
|
|
});
|
|
|
|
it("keeps a script past the target allowance unassessed", async () => {
|
|
const baseBytes = 64 * 1024;
|
|
const references = Array.from(
|
|
{ length: MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES / baseBytes + 1 },
|
|
(_, index) => `<script src="s${String(index)}"></script>`,
|
|
);
|
|
const scanned = await inspect(
|
|
await directory({
|
|
"a.html": `<base href="/${"a".repeat(baseBytes)}/">${references.join("")}`,
|
|
"b.html": '<script src="main"></script>',
|
|
main: inlineMap,
|
|
}),
|
|
);
|
|
expect(scanned.source_maps).toMatchObject({
|
|
embedded: 0,
|
|
skipped: references.length + 1,
|
|
});
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
|
|
});
|
|
|
|
it("keeps coverage unknown for a script read before its HTML file", async () => {
|
|
const scanned = await inspect(
|
|
await directory({
|
|
app: inlineMap,
|
|
"index.html": '<script src="app"></script>',
|
|
}),
|
|
);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
|
|
});
|
|
|
|
it.each([
|
|
["a missing member", "main"],
|
|
["a path from a root the audit cannot place", "/main"],
|
|
["a scheme the audit cannot resolve", "app://./main"],
|
|
])("keeps coverage unknown for %s", async (_, src) => {
|
|
const scanned = await inspect(
|
|
await directory({ "index.html": `<script src="${src}"></script>` }),
|
|
);
|
|
expect(scanned.source_maps.skipped).toBe(1);
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("unknown");
|
|
});
|
|
|
|
it("leaves coverage alone for remote, named, and data-block references", async () => {
|
|
const scanned = await inspect(
|
|
await directory({
|
|
"app.js": "run();\n",
|
|
"index.html":
|
|
'<script src="https://cdn.example/lib"></script>' +
|
|
'<script src="app.js?v=1"></script>' +
|
|
'<script type="text/plain" src="notes"></script>',
|
|
}),
|
|
);
|
|
expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 });
|
|
expect(sourceMapSourcesStatus(scanned)).toBe("not_observed");
|
|
});
|
|
});
|
|
|
|
describe("ReleaseExposureReader cumulative text allowance", () => {
|
|
const MEMBERS_PAST_ALLOWANCE = 20;
|
|
const MEMBERS_IN_ALLOWANCE =
|
|
MAX_RELEASE_EXPOSURE_TEXT_BYTES / MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES;
|
|
|
|
/** Compressed text members, then one small member the allowance decides. */
|
|
const packageRoot = async (bytes: Buffer): Promise<string> => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-text-");
|
|
await writeFile(
|
|
join(root, "app.dll"),
|
|
dotnetBundleFixture([
|
|
...Array.from({ length: MEMBERS_PAST_ALLOWANCE }, (_, index) => ({
|
|
path: `m${String(index)}.txt`,
|
|
bytes,
|
|
compressed: true,
|
|
})),
|
|
{ path: "last.txt", bytes: Buffer.from("hello\n") },
|
|
]).bytes,
|
|
);
|
|
return root;
|
|
};
|
|
|
|
const inspectCountingOpens = async (
|
|
root: string,
|
|
open?: (entry: ArtifactEntry) => Readable | undefined,
|
|
) => {
|
|
let opened = 0;
|
|
createdReaders.wrap = (reader, path) => {
|
|
if (path !== join(root, "app.dll")) return reader;
|
|
const openEntry = reader.open.bind(reader);
|
|
reader.open = (entry, signal) => {
|
|
if (entry.path.startsWith("m")) opened += 1;
|
|
const replaced = open?.(entry);
|
|
return replaced === undefined
|
|
? openEntry(entry, signal)
|
|
: Promise.resolve(replaced);
|
|
};
|
|
return reader;
|
|
};
|
|
try {
|
|
return { scanned: await inspect(root), opened: () => opened };
|
|
} finally {
|
|
createdReaders.wrap = undefined;
|
|
}
|
|
};
|
|
|
|
it("charges oversized package text reads to the allowance", async () => {
|
|
const { scanned, opened } = await inspectCountingOpens(
|
|
await packageRoot(
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
),
|
|
);
|
|
expect(opened()).toBeLessThanOrEqual(MEMBERS_IN_ALLOWANCE);
|
|
expect(scanned.textLocations).toEqual([]);
|
|
expect(scanned.text_members).toEqual({
|
|
scanned: 0,
|
|
skipped: MEMBERS_PAST_ALLOWANCE + 1,
|
|
});
|
|
});
|
|
|
|
it("charges package text that did not decode to the allowance", async () => {
|
|
const utf32 = Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES);
|
|
Buffer.from([0xff, 0xfe, 0, 0]).copy(utf32);
|
|
const { scanned, opened } = await inspectCountingOpens(
|
|
await packageRoot(utf32),
|
|
);
|
|
expect(opened()).toBe(MEMBERS_IN_ALLOWANCE);
|
|
expect(scanned.textLocations).toEqual([]);
|
|
});
|
|
|
|
it("charges the bytes of a package read that failed partway", async () => {
|
|
const { scanned, opened } = await inspectCountingOpens(
|
|
await packageRoot(Buffer.from("unread\n")),
|
|
(entry) =>
|
|
entry.path.startsWith("m")
|
|
? Readable.from(
|
|
(function* (): Generator<Buffer> {
|
|
yield Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES);
|
|
throw new Error("damaged member");
|
|
})(),
|
|
)
|
|
: undefined,
|
|
);
|
|
expect(opened()).toBe(MEMBERS_IN_ALLOWANCE);
|
|
expect(scanned.textLocations).toEqual([]);
|
|
});
|
|
|
|
it("charges only the probe for an oversized file on disk", async () => {
|
|
const root = await createTestTempDirectory("rea-release-exposure-disk-");
|
|
for (let index = 0; index < MEMBERS_PAST_ALLOWANCE; index += 1)
|
|
await writeFile(
|
|
join(root, `m${String(index)}.txt`),
|
|
Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20),
|
|
);
|
|
await writeFile(join(root, "z.txt"), "hello\n");
|
|
const scanned = await inspect(root);
|
|
expect(scanned.textLocations).toEqual([join(root, "z.txt")]);
|
|
expect(scanned.text_members).toEqual({
|
|
scanned: 1,
|
|
skipped: MEMBERS_PAST_ALLOWANCE,
|
|
});
|
|
});
|
|
});
|