fix(artifacts): diagnose unpacked ASAR integrity failures

This commit is contained in:
morluto
2026-07-13 17:00:37 +08:00
parent e933489fb5
commit a504342d2b
9 changed files with 68 additions and 6 deletions
+7
View File
@@ -272,6 +272,13 @@ const scanReader = async (
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (totalBytes > limits.maxTotalBytes)
throw new ArtifactReaderFailure(
+1 -1
View File
@@ -195,7 +195,7 @@ const translateFailure = (
cause: unknown,
): AnalysisError => {
if (cause instanceof ArtifactReaderFailure)
return new ArtifactOperationError(operation, cause.reason);
return new ArtifactOperationError(operation, cause.reason, cause.details);
return new ArtifactOperationError(operation, "io");
};
+7
View File
@@ -11,6 +11,7 @@ export interface ArtifactEntry {
readonly encrypted: boolean;
readonly byteOffset: number | null;
readonly declaredSha256: string | null;
readonly unpacked: boolean;
readonly limitations: readonly string[];
readonly adapterKey: string;
/** Filesystem identity captured during traversal, when supplied by an adapter. */
@@ -51,6 +52,12 @@ export class ArtifactReaderFailure extends Error {
| "unavailable",
message: string,
options?: ErrorOptions,
readonly details?: Readonly<{
logicalPath: string;
declaredSha256: string | null;
calculatedSha256: string | null;
unpacked: boolean;
}>,
) {
super(message, options);
this.name = "ArtifactReaderFailure";
+1
View File
@@ -43,6 +43,7 @@ export class AsarArtifactReader implements ArtifactReader {
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? metadata.integrity.hash
: null,
unpacked: "unpacked" in metadata && metadata.unpacked === true,
limitations:
kind === "symlink"
? ["ASAR symlink target was not followed or disclosed."]
+1
View File
@@ -59,6 +59,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
encrypted: false,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations:
kind === "symlink"
? ["Symlink target was not followed or disclosed."]
@@ -62,6 +62,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
encrypted: false,
byteOffset: architecture.file_offset,
declaredSha256: null,
unpacked: false,
limitations: [],
adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
};
+1
View File
@@ -69,6 +69,7 @@ export class ZipArtifactReader implements ArtifactReader {
encrypted: entry.encrypted,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations: symlink ? ["Archive symlink target was not read."] : [],
adapterKey: entry.filename,
};
+18 -4
View File
@@ -140,8 +140,18 @@ export class ArtifactOperationError extends AnalysisError {
| "path"
| "unavailable"
| "io",
readonly artifactDetails?: Readonly<{
logicalPath: string;
declaredSha256: string | null;
calculatedSha256: string | null;
unpacked: boolean;
}>,
) {
super(`Artifact ${operation} failed: ${reason}`);
super(
artifactDetails === undefined
? `Artifact ${operation} failed: ${reason}`
: `Artifact ${operation} failed: ${reason} at ${artifactDetails.logicalPath} (declared_sha256=${artifactDetails.declaredSha256 ?? "unavailable"}, calculated_sha256=${artifactDetails.calculatedSha256 ?? "unavailable"}, unpacked=${String(artifactDetails.unpacked)})`,
);
}
}
@@ -291,7 +301,7 @@ export interface AnalysisErrorProjection
extends Readonly<Record<string, JsonValue>> {
readonly tag: AnalysisErrorTag;
readonly message: string;
readonly details: Readonly<Record<string, string | number | null>>;
readonly details: Readonly<Record<string, string | number | boolean | null>>;
}
/** Project expected failures into exhaustive, secret-safe caller fields. */
@@ -308,7 +318,7 @@ export const projectAnalysisError = (
const safeDetails = (
error: AnalysisError,
): Readonly<Record<string, string | number | null>> => {
): Readonly<Record<string, string | number | boolean | null>> => {
if (error instanceof AnalysisInputError)
return { operation: error.operation };
if (error instanceof AnalysisOutputError)
@@ -328,7 +338,11 @@ const safeDetails = (
if (error instanceof ProviderAdapterError)
return { providerId: error.providerId, operation: error.operation };
if (error instanceof ArtifactOperationError)
return { operation: error.operation, reason: error.reason };
return {
operation: error.operation,
reason: error.reason,
...error.artifactDetails,
};
if (error instanceof EvidenceLimitError)
return { limit: error.limit, maximum: error.maximum };
if (error instanceof EvidenceFileError)
+31 -1
View File
@@ -10,7 +10,7 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createPackage } from "@electron/asar";
import { createPackage, createPackageWithOptions } from "@electron/asar";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
@@ -136,6 +136,36 @@ describe("artifact graph provider", () => {
expect.objectContaining({ logical_path: "main.js" }),
]),
);
const unpackedPath = join(root, "unpacked.asar");
await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" });
const unpackedResult = await inventory(target(unpackedPath, "asar"));
expect(unpackedResult.occurrences.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
hash_status: "verified",
}),
]),
);
await writeFile(
join(`${unpackedPath}.unpacked`, "main.js"),
"changed();\n",
);
const corrupted = await new ArtifactProvider()
.createClient(target(unpackedPath, "asar"))
.execute("inventory_artifact", artifactInventoryInputSchema.parse({}));
expect(corrupted).toMatchObject({
ok: false,
error: {
_tag: "ArtifactOperationError",
reason: "integrity",
artifactDetails: {
logicalPath: "main.js",
unpacked: true,
},
},
});
});
it("rejects unsafe, colliding, and over-ratio entries", async () => {