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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
Reference in new issue
Block a user