fix(evidence): authenticate identities and retain extraction provenance

Authenticate residual unknown IDs against their immutable identity fields. Permit valid empty ZIP extraction manifests and join firmware child provenance to parent-owned chunk and multi-file reports without inventing missing source ranges.

Validated with focused identity, archive, cleanup, and firmware normalization tests. Firmware joins were checked against the pinned Unblob producer; no real Unblob extraction was run.
This commit is contained in:
morluto committed 2026-10-12 14:12:19 +08:00
1 parent 4573e8cf35
commit 6bae774328
8 files changed
+334 -41

No files matched your search

@@ -1,3 +1,6 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { describe, expect, it } from "vitest";
import { EvidenceLedger } from "./EvidenceLedger.js";
@@ -373,3 +376,47 @@ describe("residual unknown registry resolution and history", () => {
expect(serializeEvidenceBundle(base.export())).toBe(before);
});
});
describe("residual unknown imported identity", () => {
it("rejects a rehashed wrong ID atomically and accepts valid history", () => {
const store = ledger();
const created = store.recordUnknown(
input("Stable question?"),
mutation("stable"),
);
if (!created.ok) throw created.error;
const initialBundle = store.export();
const mutationEvidenceId = created.value.mutation_evidence_ids[0];
if (mutationEvidenceId === undefined)
throw new Error("Missing mutation Evidence ID");
const otherId = createResidualUnknown(
input("Different question?"),
mutationEvidenceId,
created.value.scope_digest,
).unknown_id;
const { revision_digest: _revisionDigest, ...revision } = created.value;
const incorrectIdentity = { ...revision, unknown_id: otherId };
const encoded = canonicalize(incorrectIdentity);
if (encoded === undefined) throw new Error("Could not encode revision");
const tampered = {
...incorrectIdentity,
revision_digest: createHash("sha256").update(encoded).digest("hex"),
};
const invalidBundle = createEvidenceBundle(initialBundle.records, [
tampered,
]);
const before = serializeEvidenceBundle(initialBundle);
expect(() => parseEvidenceBundle(invalidBundle)).toThrow();
expect(store.import(invalidBundle).ok).toBe(false);
expect(serializeEvidenceBundle(store.export())).toBe(before);
expect(
store.updateUnknown(update(created.value), mutation("stable-update")).ok,
).toBe(true);
const validHistory = store.export();
const imported = ledger();
expect(imported.import(validHistory).ok).toBe(true);
expect(imported.export()).toEqual(validHistory);
});
});
+1 -1
View File
@@ -212,7 +212,7 @@ const extractedArtifactSchema = z.object({
const artifactExtractionManifestSchema = z.object({
extraction_id: extractionIdSchema,
source_manifest_id: manifestIdSchema,
selected_occurrence_ids: z.array(occurrenceIdSchema).min(1),
selected_occurrence_ids: z.array(occurrenceIdSchema),
files_sha256: sha256Schema,
output_root_alias: z.literal("$OUTPUT_ROOT"),
});
+25 -6
View File
@@ -19,6 +19,21 @@ const environmentRequirementSchema = z.object({
.enum(["none", "process", "container", "virtual-machine"])
.nullable(),
});
const stableUnknownId = (
question: string,
domain: string,
scopeDigest: string | null,
): string =>
`unk_${createHash("sha256")
.update(
canonicalJson({
domain: domain.trim(),
question: question.replace(/\s+/gu, " ").trim(),
scope_digest: scopeDigest,
}),
)
.digest("hex")}`;
const probeSchema = z.object({
operation: z.string().min(1),
rationale: text,
@@ -88,6 +103,15 @@ type UnknownRevision = Omit<
>;
const refineRevision = (value: UnknownRevision, context: z.RefinementCtx) => {
if (
value.unknown_id !==
stableUnknownId(value.question, value.domain, value.scope_digest)
)
context.addIssue({
code: "custom",
message: "Residual unknown ID does not match its immutable identity",
path: ["unknown_id"],
});
if ((value.revision === 1) !== (value.previous_revision_digest === null))
context.addIssue({
code: "custom",
@@ -213,13 +237,8 @@ export const createResidualUnknown = (
): ResidualUnknown => {
const question = input.question.replace(/\s+/gu, " ").trim();
const domain = input.domain.trim();
const identity = canonicalJson({
domain,
question,
scope_digest: scopeDigest,
});
const revision = {
unknown_id: `unk_${createHash("sha256").update(identity).digest("hex")}`,
unknown_id: stableUnknownId(question, domain, scopeDigest),
revision: 1,
previous_revision_digest: null,
scope_digest: scopeDigest,
+194 -8
View File
@@ -117,14 +117,17 @@ describe("Unblob multi-file extraction diagnostics", () => {
},
]);
});
});
it("retains nested MultiFile extraction failures in partial coverage", () => {
describe("Unblob task derivation identities", () => {
it("joins chunk and MultiFile derivations to their producer blob reports", () => {
const outputPath = `${context.outputRoot}/multi`;
const chunkPath = `${context.outputRoot}/chunk-child`;
const childTask = {
path: outputPath,
depth: 1,
blob_id: "multi-id",
is_multi_file: true,
is_multi_file: false,
};
const extractionFailure = {
__typename__: "ExtractorDependencyNotFoundReport",
@@ -144,10 +147,51 @@ describe("Unblob multi-file extraction diagnostics", () => {
[
{
...parent,
reports: parent.reports.filter(
(item) => item.__typename__ !== "UnknownChunkReport",
),
subtasks: [childTask],
reports: [
...parent.reports.filter(
(item) => item.__typename__ !== "UnknownChunkReport",
),
{
__typename__: "ChunkReport",
id: "chunk-id",
start_offset: 4,
end_offset: 10,
size: 6,
handler_name: "gzip",
is_encrypted: false,
extraction_reports: [],
},
multiFileReport,
],
subtasks: [
{
path: chunkPath,
depth: 1,
blob_id: "chunk-id",
is_multi_file: false,
},
childTask,
],
},
{
task: {
path: chunkPath,
depth: 1,
blob_id: "chunk-id",
is_multi_file: false,
},
reports: [
{
__typename__: "StatReport",
path: chunkPath,
size: 4,
is_file: true,
is_dir: false,
is_link: false,
link_target: null,
},
],
subtasks: [],
},
{
task: childTask,
@@ -161,7 +205,6 @@ describe("Unblob multi-file extraction diagnostics", () => {
is_link: false,
link_target: null,
},
multiFileReport,
],
subtasks: [],
},
@@ -170,9 +213,25 @@ describe("Unblob multi-file extraction diagnostics", () => {
);
expect(normalized.coverage).toBe("partial");
expect(normalized.derivations).toEqual([
{
parent_path: "$input",
child_path: "$output/chunk-child",
provider_blob_id: "chunk-id",
handler: "gzip",
parent_file_range: { offset: 4, length: 6 },
},
{
parent_path: "$input",
child_path: "$output/multi",
provider_blob_id: "multi-id",
handler: "multipart-firmware",
parent_file_range: null,
},
]);
expect(normalized.diagnostics).toEqual([
{
input_path: "$output/multi",
input_path: "$input",
stage: "multipart-firmware",
missing_dependencies: ["sasquatch"],
report: multiFileReport,
@@ -181,6 +240,133 @@ describe("Unblob multi-file extraction diagnostics", () => {
});
});
describe("Unblob blob identity validation", () => {
it("joins reports within each parent, allows missing provenance and rejects local duplicates", () => {
const rootChild = {
path: `${context.outputRoot}/root-child`,
depth: 1,
blob_id: "shared-id",
is_multi_file: false,
};
const containerTask = {
path: `${context.outputRoot}/container`,
depth: 1,
blob_id: "container-id",
is_multi_file: false,
};
const nestedChild = {
path: `${context.outputRoot}/container/nested-child`,
depth: 2,
blob_id: "shared-id",
is_multi_file: false,
};
const fileRecord = <T extends { path: string }>(task: T) => ({
task,
reports: [
{
__typename__: "StatReport",
path: task.path,
size: 1,
is_file: true,
is_dir: false,
is_link: false,
link_target: null,
},
],
subtasks: [],
});
const chunk = (
id: string,
handler: string,
start: number,
end: number,
) => ({
__typename__: "ChunkReport",
id,
start_offset: start,
end_offset: end,
size: end - start,
handler_name: handler,
is_encrypted: false,
extraction_reports: [],
});
const base = root();
const rootReports = [
...base.reports.filter(
(report) => report.__typename__ !== "UnknownChunkReport",
),
chunk("shared-id", "root-handler", 0, 4),
chunk("container-id", "container-handler", 4, 10),
];
const normalized = normalizeUnblobReport(
[
{
...base,
reports: rootReports,
subtasks: [rootChild, containerTask],
},
fileRecord(rootChild),
{
task: containerTask,
reports: [
{
__typename__: "StatReport",
path: containerTask.path,
size: 10,
is_file: true,
is_dir: false,
is_link: false,
link_target: null,
},
chunk("shared-id", "nested-handler", 2, 5),
],
subtasks: [nestedChild],
},
fileRecord(nestedChild),
],
context,
);
expect(normalized.derivations).toMatchObject([
{ handler: "root-handler", parent_file_range: { offset: 0, length: 4 } },
{
handler: "container-handler",
parent_file_range: { offset: 4, length: 6 },
},
{
handler: "nested-handler",
parent_file_range: { offset: 2, length: 3 },
},
]);
const noOrigin = {
...base,
reports: base.reports.filter(
(report) => report.__typename__ !== "UnknownChunkReport",
),
subtasks: [rootChild],
};
expect(
normalizeUnblobReport([noOrigin, fileRecord(rootChild)], context)
.derivations[0],
).toMatchObject({ handler: null, parent_file_range: null });
expect(() =>
normalizeUnblobReport(
[
{
...base,
reports: [...rootReports, chunk("shared-id", "duplicate", 10, 12)],
subtasks: [rootChild],
},
fileRecord(rootChild),
],
context,
),
).toThrow("Duplicate blob identity in parent task");
});
});
describe("firmware producer boundaries", () => {
it("rejects unknown tasks outside the owned output and disconnected task graphs", () => {
const record = root();
+27 -11
View File
@@ -57,6 +57,10 @@ const region = z.object({
end_offset: integer,
size: integer,
});
type BlobOrigin = {
readonly handler: string | null;
readonly range: { readonly offset: number; readonly length: number } | null;
};
const known = new Set([
"StatReport",
"HashReport",
@@ -191,6 +195,20 @@ export const normalizeUnblobReport = (
const sizes = new Map<string, number>();
const depths: string[] = [];
const diagnostics: JsonValue[] = [];
const blobOrigins = new Map<string, BlobOrigin>();
const addBlobOrigin = (
parentPath: string,
id: string,
origin: BlobOrigin,
) => {
const key = JSON.stringify([parentPath, id]);
if (blobOrigins.has(key))
throw new AnalysisOutputError(
"extract_firmware",
`Duplicate blob identity in parent task: ${parentPath} (${id})`,
);
blobOrigins.set(key, origin);
};
const chunks: {
input_path: string;
provider_id: string;
@@ -288,6 +306,10 @@ export const normalizeUnblobReport = (
jsonValueSchema.parse(problem),
),
);
addBlobOrigin(path, parsed.id, {
handler: detail?.handler_name ?? null,
range: { offset: parsed.start_offset, length: parsed.size },
});
chunks.push({
input_path: path,
provider_id: parsed.id,
@@ -305,6 +327,10 @@ export const normalizeUnblobReport = (
}
if (item.__typename__ === "MultiFileReport") {
const parsed = multiFile.parse(item);
addBlobOrigin(path, parsed.id, {
handler: parsed.handler_name,
range: null,
});
if (parsed.extraction_reports.length > 0)
diagnostics.push(
extractionDiagnostic(
@@ -319,16 +345,6 @@ export const normalizeUnblobReport = (
const recordsByPath = new Map(
records.map((record) => [record.task.path, record.task]),
);
const chunksByOrigin = new Map<string, (typeof chunks)[number]>();
for (const item of chunks) {
const key = JSON.stringify([item.input_path, item.provider_id]);
if (chunksByOrigin.has(key))
throw new AnalysisOutputError(
"extract_firmware",
`Duplicate chunk identity in ${item.input_path}`,
);
chunksByOrigin.set(key, item);
}
const derivations = records.flatMap((record) =>
record.subtasks.map((child) => {
const parentPath = logical(record.task.path);
@@ -348,7 +364,7 @@ export const normalizeUnblobReport = (
"extract_firmware",
`Child task identity disagrees with its analysis: ${childPath}`,
);
const origin = chunksByOrigin.get(
const origin = blobOrigins.get(
JSON.stringify([parentPath, child.blob_id]),
);
return {
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
@@ -11,7 +11,32 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { withArtifactClient } from "../../support/artifactClientScope.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
import {
artifactExtractionResultSchema,
artifactInventoryResultSchema,
} from "../../../src/domain/artifactGraph.js";
it("completes an empty ZIP extraction with an empty identity", async () => {
const root = await createTestTempDirectory("rea-empty-artifact-extraction-");
const archive = join(root, "empty.zip");
await writeOrderedZip(archive, []);
const archiveTarget = await parseBinaryTarget(archive);
if (!archiveTarget.ok) throw archiveTarget.error;
const output = join(root, "output");
const result = await withArtifactClient(archiveTarget.value, (client) =>
client.execute("extract_artifact", { output_root: output }),
);
if (!result.ok) throw result.error;
const extraction = artifactExtractionResultSchema.parse(result.value.result);
expect(extraction.extraction_manifest).toMatchObject({
source_manifest_id: extraction.manifest.manifest_id,
selected_occurrence_ids: [],
});
expect(extraction.artifacts).toEqual([]);
expect(extraction.containment_verified).toBe(true);
expect(await readdir(output)).toEqual([]);
});
it.each([
["legacy CP437", Buffer.from([0x82, 0x2e, 0x74, 0x78, 0x74]), false, "é.txt"],
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { access, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { describe, expect, it, onTestFinished, vi } from "vitest";
const injectedFilesystemFailures = vi.hoisted(() => ({
@@ -140,16 +141,17 @@ it.skipIf(process.platform === "win32").each([false, true])(
"preserves extracted output when commit close fails (persistent: %s)",
async (persistent) => {
const root = await createTestTempDirectory("rea-extraction-commit-close-");
const source = join(root, "source");
const source = join(root, "source.zip");
const output = join(root, "published");
await mkdir(source);
await writeFile(join(source, "payload.txt"), "committed bytes");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("payload.txt", new TextReader("committed bytes"));
await writeFile(source, await writer.close());
const client = new ArtifactProvider(process.env).createClient({
path: source,
sourcePath: source,
sha256: "0".repeat(64),
kind: "archive",
format: "asar",
format: "zip",
});
onTestFinished(async () => {
injectedFilesystemFailures.blockCloseRetries = false;
@@ -1,8 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
@@ -13,12 +13,10 @@ import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
describe("artifact extraction identity", () => {
it("uses code-point order for path-independent extraction identities", async () => {
const root = await createTestTempDirectory("rea-extract-order-");
const source = join(root, "source");
await mkdir(source);
for (const name of ["ä.js", "a.js", "Z.js"])
await writeFile(join(source, name), `${name}\n`);
const source = join(root, "source.zip");
await writeOrderedZip(source, ["ä.js", "a.js", "Z.js"]);
const result = await new ArtifactProvider(process.env)
.createClient(directoryTarget(source))
.createClient(archiveTarget(source))
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({
@@ -36,10 +34,10 @@ describe("artifact extraction identity", () => {
});
});
const directoryTarget = (path: string): BinaryTarget => ({
const archiveTarget = (path: string): BinaryTarget => ({
path,
sourcePath: path,
sha256: "0".repeat(64),
kind: "archive",
format: "asar",
format: "zip",
});