Files
reverse-engineering-anything/src/rust/RustBinaryImage.ts
T
0e6eca4085 feat(native): read embedded cargo-auditable Rust dependency lists (#2456)
* feat(native): read embedded cargo-auditable Rust dependency lists

* fix(native): trim PE section padding and compute dependency cycles iteratively

* fix(native): ignore SHT_NOBITS sections and validate dependency edges linearly

* fix(native): bound compiler path scanning and retained hashes

* fix(rust): pass package arrays as named parameterized test cases

* fix(rust): admit graph expansion and preserve resource units

* fix(rust): reject malformed packages, false cycles, and hidden compiler paths

Fail the first malformed cargo-auditable package before Zod collects an
issue per field. Require cyclic dependency indices to match the cycles in
package dependencies. Keep read-only ELF ranges when a section name does
not fit the bounded name window, so a rustc path in that range is still
reported.

* fix(ci): repair integrated checks and Hopper test baseline

* fix(go): verify Mach-O sections against segments

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-14 02:50:30 +08:00

182 lines
5.7 KiB
TypeScript

import {
GoBinaryFormatFailure,
GoBinaryResourceFailure,
type GoImageSection,
} from "../go/GoBinaryContainer.js";
import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
import type {
RustAuditableMetadata,
RustBinary,
} from "../domain/rust/rustBinary.js";
import {
readAuditableSection,
RustAuditableFormatFailure,
RustAuditableResourceFailure,
} from "./RustAuditableReader.js";
const RUSTC_PREFIX = Buffer.from("/rustc/");
const HASH_BYTES = 40;
/** One toolchain normally leaves one hash; a small cap bounds retained output. */
const MAX_RUSTC_HASHES = 64;
/** Container identity plus optional dependency metadata and inferred rustc commits. */
export interface RustBinaryImage {
readonly format: RustBinary["format"];
readonly architecture: string;
readonly bits: 32 | 64;
readonly byte_order: RustBinary["byte_order"];
readonly auditable_metadata: RustAuditableMetadata | null;
readonly rustc_commit: RustBinary["rustc_commit"];
}
/** Read dependency metadata from container sections without executing the image. */
export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => {
const container = readContainer(bytes);
const dependency = dependencySection(container.format, container.sections);
return {
format: container.format,
architecture: container.architecture,
bits: container.bits,
byte_order: container.byte_order,
auditable_metadata:
dependency === null
? null
: readDependency(bytes, container.format, dependency),
rustc_commit: rustcCommit(bytes, container.sections),
};
};
const readContainer = (bytes: Buffer) => {
try {
return readGoBinaryContainer(bytes);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure)
throw new RustAuditableFormatFailure(cause.kind, cause.message, {
cause,
});
if (cause instanceof GoBinaryResourceFailure)
throw new RustAuditableResourceFailure(
{
resource: "memory",
reported_limits: { maximum_bytes: cause.maximum_bytes },
},
cause.message,
{
cause,
},
);
throw cause;
}
};
const dependencySection = (
format: RustBinaryImage["format"],
sections: readonly GoImageSection[],
): GoImageSection | null => {
const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0";
const matches = sections.filter((section) => section.name === expected);
if (matches.length > 1)
throw new RustAuditableFormatFailure(
"malformed",
"Image contains more than one dependency section",
);
const section = matches[0];
if (section === undefined) return null;
if (section.compressed)
throw new RustAuditableFormatFailure(
"unsupported",
"Compressed dependency sections are unsupported",
);
if (!section.file_backed)
throw new RustAuditableFormatFailure(
"malformed",
"Dependency section is not file-backed",
);
return section;
};
const readDependency = (
bytes: Buffer,
format: RustBinaryImage["format"],
section: GoImageSection,
): RustAuditableMetadata =>
readAuditableSection(
format === "macho" ? "__DATA,.dep-v0" : ".dep-v0",
section.offset,
bytes.subarray(section.offset, section.offset + section.size),
);
const rustcCommit = (
bytes: Buffer,
sections: readonly GoImageSection[],
): RustBinaryImage["rustc_commit"] => {
const hashes: { hash: string; file_offset: number }[] = [];
const seen = new Set<string>();
// Overlapping declared sections are scanned once, so total work stays
// bounded by the file size however many sections reference a range.
for (const range of mergedReadOnlyRanges(sections))
collectHashes(bytes, range, seen, hashes);
return hashes.length === 0 ? null : { evidence_kind: "inferred", hashes };
};
const mergedReadOnlyRanges = (
sections: readonly GoImageSection[],
): { readonly offset: number; readonly end: number }[] => {
const ranges = sections
.filter((section) => section.read_only && section.size > 0)
.map((section) => ({
offset: section.offset,
end: section.offset + section.size,
}))
.sort((left, right) => left.offset - right.offset);
const merged: { offset: number; end: number }[] = [];
for (const range of ranges) {
const last = merged.at(-1);
if (last !== undefined && range.offset <= last.end)
last.end = Math.max(last.end, range.end);
else merged.push({ ...range });
}
return merged;
};
const collectHashes = (
bytes: Buffer,
range: { readonly offset: number; readonly end: number },
seen: Set<string>,
hashes: { hash: string; file_offset: number }[],
): void => {
const region = bytes.subarray(range.offset, range.end);
let cursor = 0;
while (cursor < region.length) {
const at = region.indexOf(RUSTC_PREFIX, cursor);
if (at < 0) return;
const start = at + RUSTC_PREFIX.length;
const hash = hashAt(region, start);
if (hash !== null && !seen.has(hash)) {
if (seen.size >= MAX_RUSTC_HASHES)
throw new RustAuditableResourceFailure(
{
resource: "capacity",
reported_limits: { maximum_rustc_hashes: MAX_RUSTC_HASHES },
},
`Read-only data contains more than ${MAX_RUSTC_HASHES} distinct rustc commit paths.`,
);
seen.add(hash);
hashes.push({ hash, file_offset: range.offset + at });
}
cursor = at + 1;
}
};
const hashAt = (region: Buffer, start: number): string | null => {
const end = start + HASH_BYTES;
if (end >= region.length || region[end] !== 0x2f) return null;
for (let index = start; index < end; index += 1) {
const byte = region[index] ?? 0;
const hex =
(byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66);
if (!hex) return null;
}
return region.toString("latin1", start, end);
};