mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
* fix(electron): resolve pages served from inside app.asar Packaged Electron applications load their renderer pages from file URLs inside app.asar. The host filesystem cannot stat those paths, so list_electron_targets excluded every such page as unsupported_url and inspect_electron_page could not select it. Resolve these URLs the way Electron's file protocol does: the first .asar path component that is a regular file is the archive, and the remainder must name a regular file in its header. Directories, links, and missing members stay rejected. Parsed headers are cached by archive identity. * fix(artifacts): extract and explain rewritten ASAR unpacked companions Packaging tools commonly sign or strip .asar.unpacked native binaries after writing the archive header, so their declared hashes no longer match. analyze_javascript_application already accepts integrity_policy, but extract_artifact did not, and inspect_artifact and extract_artifact still told callers to "get a fresh copy", which cannot help for a shipped app. - Accept integrity_policy on extract_artifact (MCP and CLI). The default still fails closed; record-and-continue writes the observed bytes and returns the extracted files' integrity_contradictions records. - Name the unpacked entry and the post-packaging cause in integrity failures, and point every operation that accepts integrity_policy at record-and-continue. - Report an active unpacked entry whose companion file is absent as unavailable with its logical path instead of a format failure. - Share one integrity_policy schema across the inventory, extraction, and JavaScript application inputs. * fix(electron): bound ASAR headers before allocation --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
import type { Stats } from "node:fs";
|
|
import { open, realpath, stat } from "node:fs/promises";
|
|
import { isAbsolute, join, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
|
|
export const authorizedElectronFile = async (
|
|
value: string,
|
|
): Promise<string | undefined> => {
|
|
if (/%(?:2f|5c)/iu.test(value)) return undefined;
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch (cause: unknown) {
|
|
// Non-URL input cannot authorize a local file.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
if (
|
|
url.protocol !== "file:" ||
|
|
url.hostname !== "" ||
|
|
url.username !== "" ||
|
|
url.password !== ""
|
|
)
|
|
return undefined;
|
|
let path: string;
|
|
try {
|
|
path = fileURLToPath(url);
|
|
} catch (cause: unknown) {
|
|
// Unconvertible file URLs cannot authorize a local file.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
if (!isAbsolute(path) || path.includes("\0")) return undefined;
|
|
try {
|
|
if ((await stat(path)).isFile()) return await realpath(path);
|
|
} catch (cause: unknown) {
|
|
// Electron serves packaged pages from inside app.asar, which the host
|
|
// filesystem cannot stat; resolve those through the archive header below.
|
|
void cause;
|
|
}
|
|
return await authorizedAsarMember(path);
|
|
};
|
|
|
|
/**
|
|
* Resolve `<archive>.asar/<member>` the way Electron's file protocol does: the
|
|
* first `.asar` path component that is a regular file is the archive, and the
|
|
* remainder must name a regular file in its header (packed or unpacked).
|
|
*/
|
|
const authorizedAsarMember = async (
|
|
path: string,
|
|
): Promise<string | undefined> => {
|
|
const segments = path.split(sep);
|
|
for (let index = 1; index < segments.length - 1; index += 1) {
|
|
if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue;
|
|
const archive = segments.slice(0, index + 1).join(sep);
|
|
const member = segments.slice(index + 1);
|
|
if (member.some((part) => part === "" || part === "." || part === ".."))
|
|
return undefined;
|
|
try {
|
|
const metadata = await stat(archive);
|
|
if (!metadata.isFile()) continue;
|
|
const canonical = await realpath(archive);
|
|
const files = await asarFiles(canonical, metadata);
|
|
return isAsarRegularFile(files, member)
|
|
? join(canonical, ...member)
|
|
: undefined;
|
|
} catch (cause: unknown) {
|
|
// An unreadable or malformed archive cannot authorize its members.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
type ArchiveIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs">;
|
|
|
|
// This is a retained-memory budget for JSON headers, shared across all archives.
|
|
const MAX_HEADER_BYTES = 16 * 1024 * 1024;
|
|
const MAX_CACHED_HEADERS = 8;
|
|
const headerCache = new Map<
|
|
string,
|
|
{
|
|
readonly identity: string;
|
|
readonly files: unknown;
|
|
readonly bytes: number;
|
|
}
|
|
>();
|
|
const archiveIdentity = (metadata: ArchiveIdentity): string =>
|
|
`${String(metadata.dev)}:${String(metadata.ino)}:${String(metadata.size)}:${String(metadata.mtimeMs)}`;
|
|
|
|
/** Read the two Chromium pickle headers on one handle before allocating JSON. */
|
|
const asarFiles = async (
|
|
archive: string,
|
|
metadata: ArchiveIdentity,
|
|
): Promise<unknown> => {
|
|
const identity = archiveIdentity(metadata);
|
|
const cached = headerCache.get(archive);
|
|
if (cached?.identity === identity) return cached.files;
|
|
const handle = await open(archive, "r");
|
|
let files: unknown;
|
|
let jsonBytes: number;
|
|
try {
|
|
const opened = await handle.stat();
|
|
if (!opened.isFile() || archiveIdentity(opened) !== identity)
|
|
throw new Error("ASAR identity changed before header read");
|
|
const prefix = Buffer.alloc(16);
|
|
const read = await handle.read(prefix, 0, prefix.length, 0);
|
|
if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4)
|
|
throw new Error("Truncated or invalid ASAR size pickle");
|
|
const headerBytes = prefix.readUInt32LE(4);
|
|
const payloadBytes = prefix.readUInt32LE(8);
|
|
jsonBytes = prefix.readUInt32LE(12);
|
|
if (
|
|
headerBytes < 8 ||
|
|
headerBytes > opened.size - 8 ||
|
|
payloadBytes !== headerBytes - 4 ||
|
|
jsonBytes > payloadBytes - 4 ||
|
|
jsonBytes > MAX_HEADER_BYTES
|
|
)
|
|
throw new Error(
|
|
"ASAR JSON header leaves the file or exceeds its memory budget",
|
|
);
|
|
const json = Buffer.alloc(jsonBytes);
|
|
let offset = 0;
|
|
while (offset < json.length) {
|
|
const part = await handle.read(
|
|
json,
|
|
offset,
|
|
json.length - offset,
|
|
16 + offset,
|
|
);
|
|
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
|
|
offset += part.bytesRead;
|
|
}
|
|
if (archiveIdentity(await handle.stat()) !== identity)
|
|
throw new Error("ASAR identity changed during header read");
|
|
const parsed: unknown = JSON.parse(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(json),
|
|
);
|
|
if (!isRecord(parsed) || !isRecord(parsed.files))
|
|
throw new Error("ASAR header has no file tree");
|
|
files = parsed.files;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
headerCache.delete(archive);
|
|
headerCache.set(archive, { identity, files, bytes: jsonBytes });
|
|
const retainedBytes = () =>
|
|
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
|
|
while (
|
|
headerCache.size > MAX_CACHED_HEADERS ||
|
|
retainedBytes() > MAX_HEADER_BYTES
|
|
)
|
|
headerCache.delete(headerCache.keys().next().value ?? archive);
|
|
return files;
|
|
};
|
|
|
|
const isAsarRegularFile = (
|
|
files: unknown,
|
|
member: readonly string[],
|
|
): boolean => {
|
|
let node: unknown = { files };
|
|
for (const part of member) {
|
|
const children = isRecord(node) ? node.files : undefined;
|
|
if (!isRecord(children) || !Object.hasOwn(children, part)) return false;
|
|
node = children[part];
|
|
}
|
|
return (
|
|
isRecord(node) &&
|
|
typeof node.size === "number" &&
|
|
!("files" in node) &&
|
|
!("link" in node)
|
|
);
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|