diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index 080de5458..2147a5282 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -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( diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index a2151185e..02a42f5ea 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -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"); }; diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 9f2f07163..3c9790d58 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -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"; diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 41865e507..e16ce624b 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -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."] diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index e6cc74524..cae099111 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -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."] diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index c29436de9..6235379e1 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -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)}`, }; diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 088c3af50..c01813303 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -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, }; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index be080dca2..bc34a47d1 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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> { readonly tag: AnalysisErrorTag; readonly message: string; - readonly details: Readonly>; + readonly details: Readonly>; } /** Project expected failures into exhaustive, secret-safe caller fields. */ @@ -308,7 +318,7 @@ export const projectAnalysisError = ( const safeDetails = ( error: AnalysisError, -): Readonly> => { +): Readonly> => { 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) diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index 656614dea..9092e9399 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -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 () => {