mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
fix(audit): charge sibling map reads to the map count
Every generic .map beside a selected file got a full 1 MiB read and a parse, with no cumulative bound, so the 4,096-entry listing allowed about 4 GiB of reading for one small selection. Move the slot rule into chargeMapRead, which examineMap and sibling classification now share. Past the count a sibling reads only the header: a linker map is still identified, and one that may be a source map stays a skipped companion.
This commit is contained in:
3 files changed
+144
-31
No files matched your search
@@ -54,7 +54,7 @@ A member of a package that inventory did not expand is reported as the host file
|
||||
|
||||
A package member skipped by a read allowance still counts as readable for `packaging.extractable_payload`, because the format stores it unencrypted. An encrypted member or one that failed to read counts as unreadable.
|
||||
|
||||
A map read counts against the source-map allowance when it starts, so an oversized or unreadable `.map` uses a read as a parsed one does. A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence.
|
||||
A map read counts against the source-map allowance when it starts, so an oversized or unreadable `.map` uses a read as a parsed one does. A sibling `.map` beside a selected file spends the same allowance, after the selected artifact has been read, and past it only the bounded header is read. A sibling whose header can begin a JSON object is then counted as skipped. A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence.
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
@@ -1347,6 +1347,97 @@ describe("ReleaseExposureReader source-map count", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -927,24 +927,32 @@ const examineMapMember = async (
|
||||
return loaded?.kind !== "unread";
|
||||
};
|
||||
|
||||
/**
|
||||
* Take a slot from the map count and return the bytes this read may use. The
|
||||
* read is what the count bounds, so it is charged before the file is opened:
|
||||
* an oversized or unreadable map spends its slot like a parsed one. With no
|
||||
* slot left, only the probe is read, which can still settle a linker map.
|
||||
*/
|
||||
const chargeMapRead = (
|
||||
state: ScanState,
|
||||
): { hasSlot: boolean; bytes: number } => {
|
||||
if (state.mapsRead >= MAX_RELEASE_EXPOSURE_SOURCE_MAPS)
|
||||
return { hasSlot: false, bytes: MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES };
|
||||
state.mapsRead += 1;
|
||||
return { hasSlot: true, bytes: MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES };
|
||||
};
|
||||
|
||||
/** Returns the read so a caller can also scan its text, or null when nothing was read. */
|
||||
const examineMap = async (
|
||||
state: ScanState,
|
||||
map: Pick<MemberView, "logical" | "location" | "read">,
|
||||
): Promise<MemberRead | null> => {
|
||||
const hasSlot = state.mapsRead < MAX_RELEASE_EXPOSURE_SOURCE_MAPS;
|
||||
const { hasSlot, bytes } = chargeMapRead(state);
|
||||
if (!hasSlot && isJavaScriptSourceMapName(map.logical)) {
|
||||
state.skippedMaps += 1;
|
||||
return null;
|
||||
}
|
||||
// The read is what the count bounds, so it is charged before the member is
|
||||
// opened. An oversized or unreadable map spends its slot like a parsed one.
|
||||
if (hasSlot) state.mapsRead += 1;
|
||||
const loaded = await map.read(
|
||||
hasSlot
|
||||
? MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES
|
||||
: MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
|
||||
);
|
||||
const loaded = await map.read(bytes);
|
||||
if (loaded.kind === "unread") noteUnreadMap(state, map, null);
|
||||
else if (loaded.kind === "over_allowance")
|
||||
noteUnreadMap(state, map, loaded.prefix);
|
||||
@@ -2106,27 +2114,41 @@ const scanSiblings = async (
|
||||
}
|
||||
if (!entry.name.toLowerCase().endsWith(".map") || !entry.isFile()) continue;
|
||||
if (isJavaScriptSourceMapName(entry.name)) continue;
|
||||
// Siblings are in scope only for debug-companion discovery.
|
||||
const loaded = await readDiskMember(
|
||||
sibling,
|
||||
MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES,
|
||||
undefined,
|
||||
state,
|
||||
input,
|
||||
);
|
||||
if (loaded.kind === "unread") state.skippedCompanions += 1;
|
||||
else if (loaded.kind === "over_allowance") {
|
||||
if (loaded.prefix === null || mayHaveSourceMapPrefix(loaded.prefix))
|
||||
state.skippedCompanions += 1;
|
||||
else state.companionPaths.add(sibling);
|
||||
} else {
|
||||
try {
|
||||
inspectSourceMap(loaded.bytes.toString("utf8"));
|
||||
} catch {
|
||||
if (mayHaveSourceMapPrefix(loaded.bytes)) state.skippedCompanions += 1;
|
||||
else state.companionPaths.add(sibling);
|
||||
}
|
||||
}
|
||||
await classifySiblingMap(sibling, state, input);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Siblings are in scope only for debug-companion discovery, so a sibling map
|
||||
* never reaches the selected artifact's source-map counts. Its read still
|
||||
* spends the same map count, or a directory of .map files would multiply the
|
||||
* byte allowance by the listing allowance.
|
||||
*/
|
||||
const classifySiblingMap = async (
|
||||
sibling: string,
|
||||
state: ScanState,
|
||||
input: ConsumeInput,
|
||||
): Promise<void> => {
|
||||
const { hasSlot, bytes } = chargeMapRead(state);
|
||||
const loaded = await readDiskMember(sibling, bytes, undefined, state, input);
|
||||
if (loaded.kind === "unread") {
|
||||
state.skippedCompanions += 1;
|
||||
return;
|
||||
}
|
||||
const prefix = loaded.kind === "bytes" ? loaded.bytes : loaded.prefix;
|
||||
if (loaded.kind === "bytes" && hasSlot && parsesAsSourceMap(loaded.bytes))
|
||||
return;
|
||||
if (prefix === null || mayHaveSourceMapPrefix(prefix))
|
||||
state.skippedCompanions += 1;
|
||||
else state.companionPaths.add(sibling);
|
||||
};
|
||||
|
||||
const parsesAsSourceMap = (bytes: Buffer): boolean => {
|
||||
try {
|
||||
inspectSourceMap(bytes.toString("utf8"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user