fix(artifacts): diagnose unpacked ASAR integrity failures
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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."]
|
||||
|
||||
@@ -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)}`,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user