Files
reverse-engineering-anything/src/browser/ElectronFileScope.ts
T
曾亮andmorluto 4d8d76b253 fix(electron): support packaged app.asar pages and rewritten unpacked companions (#1400)
* 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>
2026-10-10 23:01:54 +08:00

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);