fix(artifacts): apply the integrity policy to xar checksums and bound symlinks

- Inventory scans now verify xar extracted checksums themselves, under the
  caller's integrity policy. Under "fail" a mismatch still fails, now as
  artifact_integrity_mismatch with the logical path. Under
  "record-and-continue" the member stays in the graph as mismatched, with
  the declared and observed values, and is not expanded. A declared SHA-256
  becomes an integrity contradiction. Extraction keeps verifying in the reader.
- Read cpio symlink targets only up to 4096 bytes; longer declared targets
  are streamed past and recorded with a limitation instead of buffered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 12:50:52 +00:00
1 parent 667bf164f9
commit 7857d2d329
11 files changed
+212 -43

No files matched your search

@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_9b3d014e67d8c2ccc8736694d59b38acf6d8ab15d1e9cdeb3998339ff33b5cc2",
"manifest_id": "ecm_adf2c0c73407ba5b57cbfda24a2d7e02b876657936ff7cfff9e3136b51e17f29",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "17120894d8defd8c28791e344b9241e8d3abcba5ac345b0c8cca92adda518dfd"
"sha256": "92ab55fc457234c889e2f8a7043accc732ca9f6a253415c1d9758ffa9c4160ee"
}
],
"claims": [
@@ -67,7 +67,10 @@ header, depending on the binary and analyzer version.
Use `inspect_artifact` for application bundles, archives, ZIP/APK/IPA/MSIX/AppX,
ASAR, DMG, or PKG inputs. A PKG inventory includes Distribution, PackageInfo,
the pre/postinstall scripts, and the gzip payload's files. Pbzx payloads are
recorded only. Inspect it when the artifact graph and findings help answer the
recorded only. Checksums declared in the PKG's table of contents are verified;
with `integrity_policy: "record-and-continue"`, a mismatched member stays in
the graph as `mismatched`, with the declared and observed values, and is not
expanded. Inspect it when the artifact graph and findings help answer the
question. It returns the complete artifact graph inline. Cite graph manifest
IDs when using them.
+5 -1
View File
@@ -11,6 +11,8 @@ export const createReader = async (
path: string,
format: ArtifactNode["format"],
signal?: AbortSignal,
/** The caller verifies declared member checksums itself (inventory scans do). */
options: { readonly callerVerifiesChecksums?: boolean } = {},
): Promise<ArtifactReader | undefined> => {
switch (format) {
case "directory":
@@ -24,7 +26,9 @@ export const createReader = async (
case "asar":
return new AsarArtifactReader(path);
case "pkg":
return new XarArtifactReader(path);
return new XarArtifactReader(path, {
verifyChecksums: options.callerVerifiesChecksums !== true,
});
case "mach-o-universal":
return process.platform === "darwin"
? new MachOSliceArtifactReader(path)
@@ -45,7 +45,10 @@ export const scanCanonicalArtifactInventory = async (
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(createReadStream(path), options.signal);
const reader = await createReader(path, rootFormat, options.signal);
// The scan applies the integrity policy to declared member checksums.
const reader = await createReader(path, rootFormat, options.signal, {
callerVerifiesChecksums: true,
});
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
@@ -148,7 +151,14 @@ const buildInventorySnapshot = async (
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
limitations: buildLimitations(
rootFormat,
reader,
integrityContradictions,
input.occurrences.filter(
({ hash_status: status }) => status === "mismatched",
).length,
),
};
};
@@ -261,11 +271,22 @@ const buildLimitations = (
rootFormat: ArtifactNode["format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
): string[] => [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
];
mismatchedOccurrences: number,
): string[] => {
// integrity_contradictions carry SHA-256 values; other declared checksums
// (xar SHA-1/MD5) are recorded on their occurrences.
const checksumOnly = mismatchedOccurrences - integrityContradictions.length;
return [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
...(checksumOnly <= 0
? []
: [
`${String(checksumOnly)} member(s) disagree with a declared non-SHA-256 checksum; their occurrences are mismatched and name the declared and observed values.`,
]),
];
};
+36 -21
View File
@@ -129,9 +129,7 @@ const visitArtifactEntries = async (
entry.nestedArchive === "gzip-cpio";
context.registry.add(logicalPath, expandable ? "directory" : entry.kind);
const occurrence = createOccurrence(entry, logicalPath, null);
let digested:
| { readonly node: ArtifactNode; readonly mismatched: boolean }
| undefined;
let digested: DigestedEntry | undefined;
try {
digested = await digestArtifactEntry(
context,
@@ -153,6 +151,7 @@ const visitArtifactEntries = async (
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
...digested.mismatchDetails,
);
}
context.occurrences.push(occurrence);
@@ -185,39 +184,51 @@ const visitArtifactEntries = async (
}
};
interface DigestedEntry {
readonly node: ArtifactNode;
readonly mismatched: boolean;
/** Declared and observed values of a non-SHA-256 checksum that disagreed. */
readonly mismatchDetails: readonly string[];
}
const digestArtifactEntry = async (
context: ScanContext,
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
> => {
): Promise<DigestedEntry | undefined> => {
if (
(entry.kind !== "file" && entry.kind !== "slice") ||
entry.encrypted ||
entry.contentUnavailable === true
)
return undefined;
const checksum = entry.declaredChecksum;
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
context.signal,
checksum?.algorithm,
);
const mismatched =
const sha256Mismatch =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && entry.declaredSha256 !== null) {
if (context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
const checksumMismatch =
checksum !== undefined && checksum.value !== digest.also;
const mismatched = sha256Mismatch || checksumMismatch;
if (mismatched && context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
sha256Mismatch || checksum === undefined
? `Artifact integrity metadata disagrees with content: ${logicalPath}`
: `Declared ${checksum.algorithm} checksum disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (sha256Mismatch && entry.declaredSha256 !== null)
context.pendingContradictions.push({
logicalPath,
declaredSha256: entry.declaredSha256,
@@ -225,7 +236,6 @@ const digestArtifactEntry = async (
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
@@ -245,6 +255,11 @@ const digestArtifactEntry = async (
: [],
}),
mismatched,
mismatchDetails: checksumMismatch
? [
`Declared ${checksum.algorithm} ${checksum.value} disagrees with observed ${digest.also ?? "unavailable"}.`,
]
: [],
};
};
+12 -1
View File
@@ -9,6 +9,8 @@ export type HashResult = {
readonly sha256: string;
readonly bytes: number;
readonly prefix: Buffer;
/** Digest in the additionally requested algorithm, when one was requested. */
readonly also?: string;
};
/** Bounded classification evidence; this is not an executable format size limit. */
@@ -27,8 +29,11 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
export const hashReadable = async (
stream: Readable,
signal?: AbortSignal,
alsoAlgorithm?: string,
): Promise<HashResult> => {
const hash = createHash("sha256");
const also =
alsoAlgorithm === undefined ? undefined : createHash(alsoAlgorithm);
const prefixes: Buffer[] = [];
let prefixBytes = 0;
let bytes = 0;
@@ -37,6 +42,7 @@ export const hashReadable = async (
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
hash.update(chunk);
also?.update(chunk);
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
const selected = chunk.subarray(
0,
@@ -46,5 +52,10 @@ export const hashReadable = async (
prefixBytes += selected.length;
}
}
return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
return {
sha256: hash.digest("hex"),
bytes,
prefix: Buffer.concat(prefixes),
...(also === undefined ? {} : { also: also.digest("hex") }),
};
};
+9
View File
@@ -15,6 +15,15 @@ export interface ArtifactEntry {
readonly unpacked: boolean;
readonly limitations: readonly string[];
readonly adapterKey: string;
/**
* Container checksum of the member's bytes in an algorithm other than
* SHA-256 (xar declares SHA-1 or MD5). The scan verifies it under the
* caller's integrity policy.
*/
readonly declaredChecksum?: {
readonly algorithm: "sha1" | "md5" | "sha512";
readonly value: string;
};
/** The container records this member but does not hold its bytes, such as an unresolved hard link. */
readonly contentUnavailable?: boolean;
/** Archive format of this member's own bytes, when the scan should expand it. */
+12 -2
View File
@@ -13,6 +13,8 @@ const MAX_NAME_BYTES = 64 * 1024;
/** Hard-link bytes are buffered to serve every linked path, within these bounds. */
const MAX_LINK_BUFFER_BYTES = 16 * 1024 * 1024;
const MAX_LINK_BUFFER_TOTAL = 64 * 1024 * 1024;
/** Longest symlink target read; PATH_MAX is 1024 on macOS and 4096 on Linux. */
const MAX_SYMLINK_TARGET_BYTES = 4096;
const TRAILER = "TRAILER!!!";
const S_IFMT = 0o170000;
const S_IFDIR = 0o040000;
@@ -278,14 +280,22 @@ export class CpioArtifactReader implements ArtifactReader {
const type = header.mode & S_IFMT;
const path = memberPath(raw);
if (type === S_IFLNK) {
const target = await this.#collect(source, header, raw);
// A hostile header can declare a multi-gigabyte target; stream it past.
let target: Buffer | undefined;
if (header.fileSize > MAX_SYMLINK_TARGET_BYTES)
await drain(this.#verified(source, header, raw));
else target = await this.#collect(source, header, raw);
if (path !== undefined)
yield entryOf({
path,
kind: "symlink",
key,
header,
limitations: [`Symlink target: ${target.toString("utf8")}`],
limitations: [
target === undefined
? `Symlink target of ${header.fileSize} bytes exceeds ${MAX_SYMLINK_TARGET_BYTES} bytes and was not read.`
: `Symlink target: ${target.toString("utf8")}`,
],
});
return;
}
@@ -272,6 +272,39 @@ describe("installer package reader hardening", () => {
});
});
describe("cpio symlink targets", () => {
it("streams past an oversized symlink target and keeps reading", async () => {
const reader = new CpioArtifactReader(() =>
Promise.resolve(
Readable.from([
gzipCpio(
[
{ name: "./huge", mode: MODE.symlink, data: "x".repeat(4097) },
{ name: "./after", mode: MODE.file, data: "after" },
],
"crc",
),
]),
),
);
try {
const entries = await collect(reader);
expect(
entries.map(({ kind, path, limitations }) => [kind, path, limitations]),
).toEqual([
[
"symlink",
"huge",
["Symlink target of 4097 bytes exceeds 4096 bytes and was not read."],
],
["file", "after", []],
]);
} finally {
await reader.close();
}
});
});
describe("cpio hard links and CRC archives", () => {
const readAll = async (archive: Uint8Array) => {
const reader = new CpioArtifactReader(() =>
+31 -4
View File
@@ -151,6 +151,22 @@ const collectMembers = (toc: Element): XarMember[] => {
return members;
};
/** Expose a member's extracted checksum so a caller can apply its own policy. */
const declaredDigest = (
checksum: { readonly algorithm: string; readonly value: string } | undefined,
): Pick<ArtifactEntry, "declaredSha256" | "declaredChecksum"> => {
if (checksum === undefined) return { declaredSha256: null };
if (checksum.algorithm === "sha256")
return { declaredSha256: checksum.value };
const algorithm = checksum.algorithm;
return algorithm === "sha1" || algorithm === "md5" || algorithm === "sha512"
? {
declaredSha256: null,
declaredChecksum: { algorithm, value: checksum.value },
}
: { declaredSha256: null };
};
/** Verify a member's extracted checksum as its decoded bytes stream past. */
class ChecksumVerifier extends Transform {
readonly #hash: Hash;
@@ -203,7 +219,19 @@ export class XarArtifactReader implements ArtifactReader {
#heap = 0;
#members: readonly XarMember[] = [];
constructor(private readonly path: string) {}
readonly #verifyChecksums: boolean;
/**
* @param options.verifyChecksums Fail `open()` streams whose bytes disagree
* with the member's extracted checksum (default). Inventory passes false and
* verifies the declared checksum itself under the caller's integrity policy.
*/
constructor(
private readonly path: string,
options: { readonly verifyChecksums?: boolean } = {},
) {
this.#verifyChecksums = options.verifyChecksums ?? true;
}
async #load(signal?: AbortSignal): Promise<void> {
if (this.#handle !== undefined) return;
@@ -364,8 +392,7 @@ export class XarArtifactReader implements ArtifactReader {
member.data?.encoding === "application/octet-stream"
? this.#heap + member.data.offset
: null,
// xar declares SHA-1/MD5 checksums, which open() verifies itself.
declaredSha256: null,
...declaredDigest(member.data?.extractedChecksum),
unpacked: false,
limitations: [
...(member.kind === "symlink" && member.link !== undefined
@@ -409,7 +436,7 @@ export class XarArtifactReader implements ArtifactReader {
);
const checksum = data.extractedChecksum;
const verified =
checksum === undefined
checksum === undefined || !this.#verifyChecksums
? new PassThrough()
: new ChecksumVerifier(entry.path, checksum);
// Decoder errors become tagged failures; cancellation stays tagged.
@@ -114,10 +114,46 @@ describe("installer package inventory", () => {
await runProviderAnalysis(path, "inventory_artifact", {}),
).toMatchObject({
error: "Analysis failed",
details: { reason: "integrity" },
code: "artifact_integrity_mismatch",
details: { logical_path: "App.pkg/PackageInfo", declared_sha256: null },
});
});
it("records a xar checksum mismatch under record-and-continue", async () => {
const directory = await createTestTempDirectory("rea-pkg-recorded-");
const path = join(directory, "Installer.pkg");
await writeFile(path, productPackage("<plist/>", "0".repeat(40)));
const inventory = artifactInventoryResultSchema.parse(
parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {
integrity_policy: "record-and-continue",
}),
).normalized_result,
);
const info = inventory.occurrences.find(
({ logical_path: logical }) => logical === "App.pkg/PackageInfo",
);
const observedSha1 = createHash("sha1")
.update('<pkg-info identifier="com.example.app"/>')
.digest("hex");
expect(info).toMatchObject({ hash_status: "mismatched" });
expect(info?.limitations).toContain(
`Declared sha1 ${"0".repeat(40)} disagrees with observed ${observedSha1}.`,
);
expect(inventory.limitations).toContainEqual(
expect.stringContaining(
"1 member(s) disagree with a declared non-SHA-256 checksum",
),
);
// Untampered members are still inventoried and expanded.
expect(
inventory.occurrences.some(
({ logical_path: logical }) =>
logical === "App.pkg/Scripts/postinstall",
),
).toBe(true);
});
it("records an unresolved hard link as unavailable instead of empty", async () => {
const directory = await createTestTempDirectory("rea-pkg-hardlink-");
const path = join(directory, "Installer.pkg");