* feat(artifacts): make ASAR header admission configurable ASAR header/index admission was a fixed 16 MiB with no override, so operators with large Electron indexes could only extract the archive. REA_ASAR_MAX_HEADER_BYTES now sets it (default 16777216, maximum 268435456). One owner resolves the effective admission for the classification probe, inventory, ASAR reader and Electron file scope, including the Electron header cache budget. Startup and doctor validate the setting; admission refuses an invalid value instead of falling back. Recovery and error details name the effective value and its source. Fixes #6176 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): resolve ASAR header admission from the caller's environment The admission read process.env, so composition roots that select their own environment (createDirectAnalysis, createServer, CLI, optional observation factories) could not raise or lower it, and an ambient value leaked into callers that left it unset. Nothing reads process.env now. ArtifactProvider and extraction resolve the admission from their environment; JavaScript analysis parses it from the environment its server or CLI caller passes and refuses an invalid value; the Electron and V8 providers receive it from their factories. Inventory, nested ASAR readers, classification probes and Electron page authorization carry that one value. A header cached by Electron under a larger admission is refused for a smaller one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
|
import {
|
|
createAsarHeaderSignatureProbe,
|
|
type FinalAsarHeaderProbeObservation,
|
|
} from "./AsarHeader.js";
|
|
import {
|
|
createXmlPlistSignatureProbe,
|
|
MAX_XML_PLIST_PROBE_BYTES,
|
|
} from "../domain/apple/appleArchiveFormat.js";
|
|
import {
|
|
readFatContainerHeader,
|
|
readFatContainerTable,
|
|
readMachoHeaderPrefixFacts,
|
|
type FatContainerTable,
|
|
type MachoHeaderPrefixFacts,
|
|
type FatSliceHeaderEvidence,
|
|
} from "../domain/apple/machoContainer.js";
|
|
|
|
/** Initial bytes required by the other bounded format classifiers. */
|
|
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
|
|
|
|
/** Retained prefix plus bounded format-specific observations from the stream. */
|
|
export interface ArtifactClassificationEvidence {
|
|
readonly prefix: Buffer;
|
|
readonly asarHeader: FinalAsarHeaderProbeObservation;
|
|
readonly fatSliceHeaders?: FatSliceHeaderEvidence;
|
|
}
|
|
|
|
const observeFatSliceHeaders = (input: {
|
|
readonly table: FatContainerTable | undefined;
|
|
readonly observedHeaders: Map<number, MachoHeaderPrefixFacts>;
|
|
readonly partialHeaders: Map<
|
|
number,
|
|
{ readonly bytes: Buffer; count: number }
|
|
>;
|
|
readonly streamOffset: number;
|
|
readonly bytes: Uint8Array;
|
|
}): void => {
|
|
const { table, observedHeaders, partialHeaders, streamOffset, bytes } = input;
|
|
if (table?.state !== "parsed") return;
|
|
for (const slice of table.slices) {
|
|
if (observedHeaders.has(slice.offset) && !partialHeaders.has(slice.offset))
|
|
continue;
|
|
const headerLength = Math.min(12, slice.size);
|
|
const headerEnd = slice.offset + headerLength;
|
|
if (!Number.isSafeInteger(headerEnd)) continue;
|
|
const start = Math.max(slice.offset, streamOffset);
|
|
const end = Math.min(headerEnd, streamOffset + bytes.length);
|
|
if (start >= end) continue;
|
|
let pending = partialHeaders.get(slice.offset);
|
|
if (pending === undefined) {
|
|
pending = { bytes: Buffer.alloc(12), count: 0 };
|
|
partialHeaders.set(slice.offset, pending);
|
|
}
|
|
Buffer.from(bytes.subarray(start - streamOffset, end - streamOffset)).copy(
|
|
pending.bytes,
|
|
start - slice.offset,
|
|
);
|
|
pending.count += end - start;
|
|
observedHeaders.set(
|
|
slice.offset,
|
|
readMachoHeaderPrefixFacts(
|
|
pending.bytes.subarray(0, Math.min(pending.count, headerLength)),
|
|
),
|
|
);
|
|
if (pending.count === headerLength) {
|
|
partialHeaders.delete(slice.offset);
|
|
}
|
|
}
|
|
};
|
|
|
|
const finishClassificationEvidence = (
|
|
prefix: Buffer,
|
|
archiveBytes: number,
|
|
finishAsar: (archiveBytes: number) => FinalAsarHeaderProbeObservation,
|
|
sliceHeadersByOffset: Map<number, MachoHeaderPrefixFacts>,
|
|
): ArtifactClassificationEvidence => {
|
|
const finalHeader = readFatContainerHeader(prefix, archiveBytes);
|
|
if (finalHeader.state === "parsed") {
|
|
for (let index = 0; index < finalHeader.slices.length; index += 1) {
|
|
const slice = finalHeader.slices[index];
|
|
if (slice === undefined || sliceHeadersByOffset.has(slice.offset))
|
|
continue;
|
|
const available = Math.max(
|
|
0,
|
|
Math.min(slice.size, prefix.length - slice.offset),
|
|
);
|
|
if (available === 0) continue;
|
|
sliceHeadersByOffset.set(
|
|
slice.offset,
|
|
readMachoHeaderPrefixFacts(
|
|
prefix.subarray(slice.offset, slice.offset + available),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const fatSliceHeaders =
|
|
finalHeader.state === "parsed"
|
|
? { header: finalHeader, sliceHeadersByOffset }
|
|
: undefined;
|
|
return {
|
|
prefix,
|
|
asarHeader: finishAsar(archiveBytes),
|
|
...(fatSliceHeaders === undefined ? {} : { fatSliceHeaders }),
|
|
};
|
|
};
|
|
|
|
/** Share acquisition bounds and signature observations across files and streams. */
|
|
export const createArtifactClassificationProbe = (
|
|
asarHeaderAdmission?: AsarHeaderAdmission,
|
|
) => {
|
|
const xml = createXmlPlistSignatureProbe();
|
|
const asar = createAsarHeaderSignatureProbe(asarHeaderAdmission);
|
|
const blocks: Buffer[] = [];
|
|
let retainedBytes = 0;
|
|
let observedBytes = 0;
|
|
let fatTable: FatContainerTable | undefined;
|
|
const fatSliceHeadersByOffset = new Map<number, MachoHeaderPrefixFacts>();
|
|
const partialFatHeaders = new Map<
|
|
number,
|
|
{ readonly bytes: Buffer; count: number }
|
|
>();
|
|
const retain = (bytes: Uint8Array): void => {
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const blockOffset = retainedBytes % ARTIFACT_CLASSIFICATION_PREFIX_BYTES;
|
|
if (blockOffset === 0)
|
|
blocks.push(Buffer.allocUnsafe(ARTIFACT_CLASSIFICATION_PREFIX_BYTES));
|
|
const count = Math.min(
|
|
ARTIFACT_CLASSIFICATION_PREFIX_BYTES - blockOffset,
|
|
bytes.length - offset,
|
|
);
|
|
blocks[blocks.length - 1]!.set(
|
|
bytes.subarray(offset, offset + count),
|
|
blockOffset,
|
|
);
|
|
retainedBytes += count;
|
|
offset += count;
|
|
}
|
|
};
|
|
const retainedPrefix = (): Buffer => {
|
|
const prefix = Buffer.allocUnsafe(retainedBytes);
|
|
let copied = 0;
|
|
for (const block of blocks) {
|
|
const count = Math.min(block.length, retainedBytes - copied);
|
|
prefix.set(block.subarray(0, count), copied);
|
|
copied += count;
|
|
}
|
|
return prefix;
|
|
};
|
|
return {
|
|
get length(): number {
|
|
return observedBytes;
|
|
},
|
|
get limit(): number {
|
|
if (observedBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES)
|
|
return ARTIFACT_CLASSIFICATION_PREFIX_BYTES;
|
|
return Math.max(
|
|
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
|
|
xml.state === "pending" ? MAX_XML_PLIST_PROBE_BYTES : 0,
|
|
asar.observation.state === "pending" ? asar.limit : 0,
|
|
);
|
|
},
|
|
write(bytes: Uint8Array): void {
|
|
const streamOffset = observedBytes;
|
|
asar.write(bytes);
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const limit =
|
|
retainedBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES
|
|
? ARTIFACT_CLASSIFICATION_PREFIX_BYTES
|
|
: xml.state === "pending"
|
|
? MAX_XML_PLIST_PROBE_BYTES
|
|
: retainedBytes;
|
|
if (retainedBytes >= limit) break;
|
|
const selected = bytes.subarray(
|
|
offset,
|
|
offset + Math.min(limit - retainedBytes, bytes.length - offset),
|
|
);
|
|
retain(selected);
|
|
xml.write(selected);
|
|
offset += selected.length;
|
|
}
|
|
if (
|
|
(fatTable === undefined || fatTable.state === "needs-data") &&
|
|
retainedBytes >= 8
|
|
)
|
|
fatTable = readFatContainerTable(retainedPrefix());
|
|
observeFatSliceHeaders({
|
|
table: fatTable,
|
|
observedHeaders: fatSliceHeadersByOffset,
|
|
partialHeaders: partialFatHeaders,
|
|
streamOffset,
|
|
bytes,
|
|
});
|
|
observedBytes += bytes.length;
|
|
},
|
|
finish(archiveBytes: number): ArtifactClassificationEvidence {
|
|
return finishClassificationEvidence(
|
|
retainedPrefix(),
|
|
archiveBytes,
|
|
(bytes) => asar.finish(bytes),
|
|
fatSliceHeadersByOffset,
|
|
);
|
|
},
|
|
};
|
|
};
|