From ff6b3fb9a131675d06d1fc589b50331cd3cd9629 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:23:53 +0800 Subject: [PATCH] fix(evidence): admit rebound APK and WASM views --- .../apkSignature/ApkSignatureService.test.ts | 29 +++++++++ .../apkSignature/ApkSignatureService.ts | 1 + .../wasm/WasmArtifactService.test.ts | 59 ++++++++++++++++++- src/application/wasm/WasmArtifactService.ts | 1 + .../WasmInterfaceComparisonService.test.ts | 4 +- .../wasm/WasmInterfaceComparisonService.ts | 1 - src/domain/analysisView/apkSignatureView.ts | 1 - src/domain/analysisView/wasmArtifactView.ts | 3 +- src/domain/apkSignature/apkSignatureReport.ts | 5 +- src/domain/wasm/wasmArtifact.ts | 6 +- .../apkSignatureViewIdentity.test.ts | 25 +++++++- 11 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/application/apkSignature/ApkSignatureService.test.ts b/src/application/apkSignature/ApkSignatureService.test.ts index 2ca6181cc9..5d6f8f2c07 100644 --- a/src/application/apkSignature/ApkSignatureService.test.ts +++ b/src/application/apkSignature/ApkSignatureService.test.ts @@ -76,3 +76,32 @@ it("refuses summary acquisition before reading when complete Evidence cannot be error: { _tag: "AnalysisCapabilityUnavailableError" }, }); }); + +it("rejects a live provider subject path that differs from the selected APK", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-apk-subject-")); + const provider = new ApkSignatureProvider(); + try { + const path = join(root, "selected.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("classes.dex", new TextReader("x")); + await writeFile(path, await writer.close()); + const inspected = await provider.inspect({ path }); + if (!inspected.ok) throw inspected.error; + const execution = inspected.value; + const service = new ApkSignatureService({ + close: () => Promise.resolve(), + inspect: async () => + ok({ + ...execution, + subject: { ...execution.subject, path: join(root, "other.apk") }, + }), + }); + expect(await service.inspect({ path, detail: "complete" })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + } finally { + await provider.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/application/apkSignature/ApkSignatureService.ts b/src/application/apkSignature/ApkSignatureService.ts index 74ec0731a5..019f8d3914 100644 --- a/src/application/apkSignature/ApkSignatureService.ts +++ b/src/application/apkSignature/ApkSignatureService.ts @@ -70,6 +70,7 @@ export class ApkSignatureService { if ( !report.success || report.data.artifact.path !== input.value.path || + execution.subject.path !== input.value.path || !apkSignatureMatchesSubject(report.data.artifact, execution.subject) ) return err( diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts index 94a878f64b..8b5180c0fe 100644 --- a/src/application/wasm/WasmArtifactService.test.ts +++ b/src/application/wasm/WasmArtifactService.test.ts @@ -1,3 +1,7 @@ +import { rebindEvidenceToTargetPath } from "../../domain/evidence.js"; +import { EvidenceLedger } from "../investigation/EvidenceLedger.js"; +import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; +import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js"; import { AnalysisSettings } from "../AnalysisSettings.js"; import { expect, it } from "vitest"; import { WasmArtifactService } from "./WasmArtifactService.js"; @@ -61,7 +65,7 @@ it("rejects a provider report that silently changes the selected command budget" }), ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } }); }); -it.each(["path", "digest", "wat", "candidates", "glue"])( +it.each(["path", "subject-path", "digest", "wat", "candidates", "glue"])( "rejects mismatched upstream observation: %s", async (problem) => { const value = wasmArtifactFixture(); @@ -76,7 +80,10 @@ it.each(["path", "digest", "wat", "candidates", "glue"])( ok({ ...execution, subject: { - path: "/artifacts/module.wasm", + path: + problem === "subject-path" + ? "/different" + : "/artifacts/module.wasm", format: "file", sha256: problem === "digest" ? "b".repeat(64) : value.artifact.sha256, @@ -255,3 +262,51 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an expect(accepted).toEqual([123000, 42000, 30000]); expect(authored).toEqual([undefined, 42000, undefined]); }); + +it("replays imported rebound WASM Evidence in views and comparisons", async () => { + const report = wasmArtifactFixture(); + const service = new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: async () => ok(wasmArtifactExecution(report)), + }); + const completed = await service.inspect({ path: report.artifact.path }); + if (!completed.ok) throw completed.error; + const rebound = rebindEvidenceToTargetPath(completed.value, { + path: "/alias/module.wasm", + sha256: report.artifact.sha256, + format: "file", + }); + const exporting = new EvidenceLedger(); + expect(exporting.record(rebound).ok).toBe(true); + const imported = new EvidenceLedger(); + expect(imported.import(exporting.export()).ok).toBe(true); + const lookup = (id: string) => imported.forAnalysis(id); + const replay = await inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: rebound.evidence_id }, + view: { kind: "summary" }, + }, + lookup, + ); + if (!replay.ok) throw replay.error; + expect(replay.value.subject?.local_path).toBe("/alias/module.wasm"); + expect(replay.value.normalized_result).toMatchObject({ + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + }); + expect( + ( + await compareWasmInterfaces( + { + left: { kind: "retained-evidence", evidence_id: rebound.evidence_id }, + right: { + kind: "retained-evidence", + evidence_id: rebound.evidence_id, + }, + scope: "both", + }, + {}, + lookup, + ) + ).ok, + ).toBe(true); +}); diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts index 2545311805..b6f1619b8f 100644 --- a/src/application/wasm/WasmArtifactService.ts +++ b/src/application/wasm/WasmArtifactService.ts @@ -55,6 +55,7 @@ export class WasmArtifactService { if ( !report.success || report.data.artifact.path !== input.value.path || + execution.subject.path !== input.value.path || report.data.tool_profile.timeout_ms !== timeout || !wasmArtifactMatchesSubject(report.data.artifact, execution.subject) || JSON.stringify(report.data.glue.map((item) => item.artifact.path)) !== diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts index 25d7a2fc81..9948c6643a 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.test.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts @@ -131,7 +131,7 @@ it("withholds absence claims for historical or partial inventories and unsupport }); }); -it("authenticates semantic Evidence and binds display subject and selection identity", async () => { +it("authenticates semantic Evidence and rejects altered artifact digests", async () => { const left = JSON.parse(JSON.stringify(evidence(facts("f")))); left.normalized_result.interface.entries[0].name = "tampered"; expect( @@ -142,7 +142,7 @@ it("authenticates semantic Evidence and binds display subject and selection iden }), ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); const rebound = JSON.parse(JSON.stringify(evidence(facts("f")))); - rebound.subject.local_path = "/other.wasm"; + rebound.subject.digest.sha256 = "b".repeat(64); expect( await compareWasmInterfaces({ left: rebound, diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts index d4a6e16226..ec6478e551 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.ts @@ -50,7 +50,6 @@ const parseInspection = ( evidence.subject === null ? null : { - path: evidence.subject.local_path, sha256: evidence.subject.digest.sha256, format: evidence.subject.format, }, diff --git a/src/domain/analysisView/apkSignatureView.ts b/src/domain/analysisView/apkSignatureView.ts index 6a492401c0..8172766d2e 100644 --- a/src/domain/analysisView/apkSignatureView.ts +++ b/src/domain/analysisView/apkSignatureView.ts @@ -101,7 +101,6 @@ const read = (parent: AnalysisViewParent): Result => { if ( parent.subject != null && !apkSignatureMatchesSubject(report.artifact, { - path: parent.subject.local_path, sha256: parent.subject.digest.sha256, format: parent.subject.format, }) diff --git a/src/domain/analysisView/wasmArtifactView.ts b/src/domain/analysisView/wasmArtifactView.ts index be1c0c6e8f..de6b65fb31 100644 --- a/src/domain/analysisView/wasmArtifactView.ts +++ b/src/domain/analysisView/wasmArtifactView.ts @@ -95,7 +95,6 @@ export const projectWasmArtifactView = ( parent.subject == null ? null : { - path: parent.subject.local_path, sha256: parent.subject.digest.sha256, format: parent.subject.format, }, @@ -107,7 +106,7 @@ export const projectWasmArtifactView = ( path: ["source", "subject"], reason: "invalid_value", message: - "WASM parent Evidence subject must identify the report's exact path and SHA-256.", + "WASM parent Evidence subject must identify the report's file format and SHA-256.", }, ]), ); diff --git a/src/domain/apkSignature/apkSignatureReport.ts b/src/domain/apkSignature/apkSignatureReport.ts index 2f5207fc3a..41565816b9 100644 --- a/src/domain/apkSignature/apkSignatureReport.ts +++ b/src/domain/apkSignature/apkSignatureReport.ts @@ -440,13 +440,12 @@ export const deriveApkPosture = ( }; }; -/** The returned artifact identity must match the caller-selected local file. */ +/** Match semantic artifact identity; local subject paths are delivery metadata. */ export const apkSignatureMatchesSubject = ( artifact: ApkSignatureResult["artifact"], - subject: EvidenceSubjectTarget, + subject: Pick, ): boolean => artifact.sha256 === subject.sha256 && - artifact.path === subject.path && (subject.format === "file" || subject.format === "apk" || subject.format === "zip"); diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index 08fb0f4318..b0d3e8d479 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -174,20 +174,20 @@ export type InspectWasmArtifactInput = z.infer< >; export type WasmArtifact = z.infer; -/** A WASM observation must identify the selected file path and byte digest. */ +/** Semantic WASM subject identity excludes delivery-only local paths. */ export type WasmArtifactSubject = Pick< EvidenceSubjectTarget, - "path" | "sha256" | "format" + "sha256" | "format" >; type WasmArtifactFileSubject = WasmArtifactSubject & { readonly format: "file"; }; +/** Match semantic artifact identity; local subject paths are delivery metadata. */ export const wasmArtifactMatchesSubject = ( artifact: WasmArtifact["artifact"], subject: WasmArtifactSubject | null | undefined, ): subject is WasmArtifactFileSubject => subject != null && - subject.path === artifact.path && subject.sha256 === artifact.sha256 && subject.format === "file"; diff --git a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts index 7f89c736c8..c7f8863133 100644 --- a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts +++ b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts @@ -1,3 +1,6 @@ +import { rebindEvidenceToTargetPath } from "../../../src/domain/evidence.js"; +import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -39,9 +42,29 @@ it("checks parent subject identity again when signing facts are already cached", { kind: "summary" }, ).ok, ).toBe(true); + const rebound = rebindEvidenceToTargetPath(evidence, { + path: join(root, "alias.apk"), + sha256: evidence.subject.digest.sha256, + format: evidence.subject.format, + }); + const exporting = new EvidenceLedger(); + expect(exporting.record(rebound).ok).toBe(true); + const imported = new EvidenceLedger(); + expect(imported.import(exporting.export()).ok).toBe(true); + const replay = await inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: rebound.evidence_id }, + view: { kind: "summary" }, + }, + (id) => imported.forAnalysis(id), + ); + if (!replay.ok) throw replay.error; + expect(replay.value.subject?.local_path).toBe(join(root, "alias.apk")); + expect(replay.value.normalized_result).toMatchObject({ + artifact: { path }, + }); for (const subject of [ { ...evidence.subject, format: "directory" as const }, - { ...evidence.subject, local_path: join(root, "other.apk") }, { ...evidence.subject, digest: { ...evidence.subject.digest, sha256: "00".repeat(32) },