fix(evidence): admit rebound APK and WASM views
This commit is contained in:
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,
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
@@ -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) },
|
||||
|
||||
Reference in new issue
Block a user