fix(evidence): admit rebound APK and WASM views

This commit is contained in:
morluto committed 2026-10-27 12:23:56 +08:00
1 parent 14dd07e740
commit ff6b3fb9a1
11 files changed
+120 -15

No files matched your search

@@ -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 });
}
});
@@ -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(
@@ -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);
});
@@ -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)) !==
@@ -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,
@@ -50,7 +50,6 @@ const parseInspection = (
evidence.subject === null
? null
: {
path: evidence.subject.local_path,
sha256: evidence.subject.digest.sha256,
format: evidence.subject.format,
},
@@ -101,7 +101,6 @@ const read = (parent: AnalysisViewParent): Result<Checked, AnalysisError> => {
if (
parent.subject != null &&
!apkSignatureMatchesSubject(report.artifact, {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
format: parent.subject.format,
})
+1 -2
View File
@@ -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.",
},
]),
);
@@ -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<EvidenceSubjectTarget, "sha256" | "format">,
): boolean =>
artifact.sha256 === subject.sha256 &&
artifact.path === subject.path &&
(subject.format === "file" ||
subject.format === "apk" ||
subject.format === "zip");
+3 -3
View File
@@ -174,20 +174,20 @@ export type InspectWasmArtifactInput = z.infer<
>;
export type WasmArtifact = z.infer<typeof wasmArtifactSchema>;
/** 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";
@@ -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) },