mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
* 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>
182 lines
5.7 KiB
TypeScript
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);
|
|
};
|