fix(evidence): separate source and presentation identity

This commit is contained in:
morluto committed 2026-10-27 12:06:29 +08:00
1 parent 3e8923c6eb
commit 2f27d3ac92
17 files changed
+214 -48

No files matched your search

@@ -33,6 +33,7 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { err, ok } from "../../domain/result.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
@@ -43,6 +44,85 @@ import {
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import * as evidenceFileSource from "./evidenceFileSource.js";
it("projects retained GPU source facts while delivering the caller's rebound subject", () => {
const sha256 = "a".repeat(64);
const original = createEvidence(
{ path: "/original/gpu", sha256, format: "elf" },
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "inspect_gpu_code_objects",
parameters: {},
result: {
artifact: {
path: "/original/gpu",
file_size: 0,
sha256,
bytes_read: 0,
},
format: "elf",
analysis: "inventory",
objects: [],
kernels: [],
ptx_artifacts: [],
coverage: {
discovery_scope: "supported-section-names-and-standalone-formats",
discovery: "partial",
payloads: "not-requested",
examined_sections: 0,
unknown_section_names: 0,
},
limitations: [],
},
},
);
const ledger = new EvidenceLedger();
expect(ledger.record(original).ok).toBe(true);
const retained = ledger.forAnalysis(original.evidence_id)!;
const payload = retained.normalized_result;
const selected = rebindEvidenceToTargetPath(original, {
path: "/selected/gpu",
sha256,
format: "elf",
});
expect(selected.evidence_id).toBe(original.evidence_id);
const retention = {
recordEvidence: (evidence: Evidence) => ledger.record(evidence),
evidenceById: (id: string) => ledger.forAnalysis(id),
};
for (const view of [
{ kind: "summary" as const },
{
kind: "gpu-code-objects" as const,
collection: "objects" as const,
offset: 0,
limit: 64,
},
]) {
const result = projectRetainedAnalysis(selected, retention, view);
if (!result.ok) throw result.error;
expect(result.value.subject?.local_path).toBe("/selected/gpu");
expect(result.value.normalized_result).toMatchObject({
parent_evidence_id: original.evidence_id,
artifact: { path: "/original/gpu", sha256 },
});
}
expect(ledger.forAnalysis(original.evidence_id)).toBe(retained);
expect(retained.normalized_result).toBe(payload);
expect(retained.subject?.local_path).toBe("/original/gpu");
const mismatched = createEvidence(
{ path: "/original/gpu", sha256: "b".repeat(64), format: "elf" },
original.provider,
{
operation: original.operation,
parameters: {},
result: original.normalized_result,
},
);
expect(summarizeRetainedAnalysis(mismatched, retention)).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it.each(["inline", "retained-evidence", "invalid"] as const)(
"refuses an already cancelled %s view before source admission",
async (kind) => {
@@ -208,6 +208,7 @@ const projectResolvedAnalysisView = (
// Sealing an Evidence root deep-freezes it; only the root is registered.
sealedResult: isImmutableJsonSnapshot(parent),
subject: presentation.subject,
sourceSubject: parent.subject,
limitations: parent.limitations,
parameters: parent.parameters,
artifact:
@@ -18,7 +18,11 @@ import {
type AnalysisViewRequest,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisView.js";
import { inspectAnalysisView } from "./AnalysisViewService.js";
import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
const provider = { id: "fixture", name: "Fixture provider", version: "1" };
const parent = (
@@ -494,6 +498,27 @@ it("keeps context identity across authenticated target path aliases while preser
});
expect(second.evidence.subject?.local_path).toBe("/other/alias.elf");
expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id);
const ledger = new EvidenceLedger();
expect(ledger.record(original).ok).toBe(true);
const retained = ledger.forAnalysis(original.evidence_id)!;
const delivered = projectRetainedAnalysis(
rebound,
{
recordEvidence: (evidence) => ledger.record(evidence),
evidenceById: (id) => ledger.forAnalysis(id),
},
view,
);
if (!delivered.ok) throw delivered.error;
expect(delivered.value.subject?.local_path).toBe("/other/alias.elf");
expect(delivered.value.normalized_result).toMatchObject({
artifact: { path: "/other/alias.elf" },
items: {
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
},
});
expect(ledger.forAnalysis(original.evidence_id)).toBe(retained);
expect(retained.subject?.local_path).toBe("/selected/fixture.elf");
});
it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => {
@@ -13,7 +13,10 @@ import {
} from "../webNetworkCapture.js";
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { analysisViewResultInputError } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
analysisViewResultInputError,
} from "./analysisViewParent.js";
import type { AnalysisViewParent } from "./analysisView.js";
import {
ENTRY_BYTES,
@@ -150,12 +153,13 @@ export const admitHarCaptureParent = (
// Environment views cite the report path and digest, so their parent's subject must be exactly them;
// the api-surface view keeps the admission its existing callers rely on.
const { artifact } = parsed.data;
const sourceSubject = analysisViewSourceSubject(parent);
if (
viewKind !== "api-surface" &&
artifact.sha256 !== null &&
(parent.subject == null ||
parent.subject.local_path !== artifact.path ||
parent.subject.digest.sha256 !== artifact.sha256)
(sourceSubject == null ||
sourceSubject.local_path !== artifact.path ||
sourceSubject.digest.sha256 !== artifact.sha256)
)
return err(
analysisViewInputError([
+3 -1
View File
@@ -1437,8 +1437,10 @@ export interface AnalysisViewParent {
readonly environment?: Evidence["environment"];
/** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */
readonly sealedResult?: boolean;
/** Preserve the authenticated source identity, including explicit absence. */
/** Subject presentation for this delivery, including explicit absence. */
readonly subject?: Evidence["subject"];
/** Original retained identity for report validation; omitted for direct domain callers. */
readonly sourceSubject?: Evidence["subject"];
readonly limitations: readonly string[];
readonly artifact?: { readonly path: string; readonly sha256: string } | null;
/** Parent Evidence parameters, when the parent operation records them. */
@@ -5,7 +5,10 @@ import {
isImmutableJsonSnapshot,
} from "../immutableJson.js";
import type { AnalysisViewParent } from "./analysisView.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
const parent = (normalizedResult: unknown): AnalysisViewParent => ({
evidenceId: `ev_${"a".repeat(64)}`,
@@ -15,6 +18,14 @@ const parent = (normalizedResult: unknown): AnalysisViewParent => ({
limitations: [],
});
it("keeps explicit source absence separate from a delivery subject", () => {
const source = parent({});
expect(analysisViewSourceSubject(source)).toBeUndefined();
expect(
analysisViewSourceSubject({ ...source, sourceSubject: null }),
).toBeNull();
});
it("does not trust a caller's sealed flag or shallow freeze for mutable facts", () => {
const item = { value: "valid" };
const source = Object.freeze({ items: [item] });
@@ -67,3 +67,9 @@ export const analysisViewResultInputError = (
{ source: { normalized_result: input } },
{ cause },
);
/** Resolve report-validation identity once, preserving explicit source absence. */
export const analysisViewSourceSubject = (
parent: AnalysisViewParent,
): AnalysisViewParent["subject"] =>
parent.sourceSubject === undefined ? parent.subject : parent.sourceSubject;
@@ -11,7 +11,10 @@ import {
type DesktopApplication,
} from "../desktopApplication.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
parseAnalysisViewResult,
} from "./analysisViewParent.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
@@ -181,12 +184,13 @@ export const projectDesktopApplicationView = (
validatedParents.add(key);
}
}
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject === undefined ||
parent.subject === null ||
parent.subject.format !== "directory" ||
parent.subject.local_path !== report.root.path ||
parent.subject.digest.sha256 !== report.root.snapshot_sha256
sourceSubject === undefined ||
sourceSubject === null ||
sourceSubject.format !== "directory" ||
sourceSubject.local_path !== report.root.path ||
sourceSubject.digest.sha256 !== report.root.snapshot_sha256
)
return err(
analysisViewInputError([
+9 -5
View File
@@ -4,7 +4,10 @@ import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { evmInterfaceSchema } from "../evm/evmInterface.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
parseAnalysisViewResult,
} from "./analysisViewParent.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
@@ -66,11 +69,12 @@ export const projectEvmInterfaceView = (
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject == null ||
parent.subject.format !== "file" ||
parent.subject.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256
sourceSubject == null ||
sourceSubject.format !== "file" ||
sourceSubject.local_path !== report.artifact.path ||
sourceSubject.digest.sha256 !== report.artifact.sha256
)
return err(
analysisViewInputError([
+9 -5
View File
@@ -20,7 +20,10 @@ import {
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
import { selectViewRows } from "./selectViewRows.js";
const indexSchema = z.number().int().nonnegative();
@@ -122,11 +125,12 @@ export const projectGoBinaryView = (
const parsed = parseReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject?.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256 ||
parent.subject.format !== metadataReportSubjectFormat(report) ||
parent.subject.architecture !==
sourceSubject?.local_path !== report.artifact.path ||
sourceSubject.digest.sha256 !== report.artifact.sha256 ||
sourceSubject.format !== metadataReportSubjectFormat(report) ||
sourceSubject.architecture !==
(binaryMetadataArchitecture(report.architecture) ?? null)
)
return err(
@@ -18,7 +18,10 @@ import type {
UnsignedAnalysisView,
} from "./analysisView.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
@@ -193,10 +196,11 @@ const readGpuFacts = (
};
if (reusable) checkedGpu.set(report, checked);
}
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject != null &&
(parent.subject.local_path !== checked.report.artifact.path ||
parent.subject.digest.sha256 !== checked.report.artifact.sha256)
sourceSubject != null &&
(sourceSubject.local_path !== checked.report.artifact.path ||
sourceSubject.digest.sha256 !== checked.report.artifact.sha256)
)
return err(
analysisViewInputError([
@@ -24,7 +24,10 @@ import {
il2cppMetadataTypeSchema,
} from "../il2cpp/il2cppMetadata.js";
import { err, ok, type Result } from "../result.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
@@ -535,11 +538,12 @@ export const projectIl2cppMetadataView = (
const parsed = parseMetadataReport(parent);
if (!parsed.ok) return parsed;
const { report, counts } = parsed.value;
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject != null &&
(parent.subject.format !== "file" ||
parent.subject.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256)
sourceSubject != null &&
(sourceSubject.format !== "file" ||
sourceSubject.local_path !== report.artifact.path ||
sourceSubject.digest.sha256 !== report.artifact.sha256)
)
return err(
analysisViewInputError([
@@ -1,3 +1,4 @@
import { analysisViewSourceSubject } from "./analysisViewParent.js";
import { javascriptSourceRangeSchema } from "../javascript/javascriptSourceCoordinates.js";
import { z } from "zod";
import { webBundleViewRequestSchema } from "./webBundleView.js";
@@ -344,10 +345,11 @@ export const projectJavaScriptRoutesView = (
path: identity.data.input_path,
sha256: identity.data.root_artifact_sha256,
};
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject != null &&
(parent.subject.local_path !== artifact.path ||
parent.subject.digest.sha256 !== artifact.sha256)
sourceSubject != null &&
(sourceSubject.local_path !== artifact.path ||
sourceSubject.digest.sha256 !== artifact.sha256)
)
return err(
inputError(
+8 -4
View File
@@ -10,7 +10,10 @@ import {
} from "../model/modelArtifact.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
import { isImmutableJsonSnapshot } from "../immutableJson.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
@@ -142,10 +145,11 @@ const read = (parent: AnalysisViewParent): Result<Checked, AnalysisError> => {
const report = parsed.value;
const reusable = isImmutableJsonSnapshot(report);
const cached = reusable ? checked.get(report) : undefined;
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject != null &&
(parent.subject.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256)
sourceSubject != null &&
(sourceSubject.local_path !== report.artifact.path ||
sourceSubject.digest.sha256 !== report.artifact.sha256)
)
return err(
analysisViewInputError([
+7 -3
View File
@@ -1,7 +1,10 @@
import { analysisViewInputError } from "./analysisViewErrors.js";
import { z } from "zod";
import { pcapEvidenceSchemas } from "../pcap/pcapAnalysis.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
parseAnalysisViewResult,
} from "./analysisViewParent.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { err, ok, type Result } from "../result.js";
import type {
@@ -113,9 +116,10 @@ const parseParent = (
): Result<Capture, AnalysisError> => {
const parsed = parseAnalysisViewResult(captureSchema, parent);
if (!parsed.ok) return parsed;
const sourceSubject = analysisViewSourceSubject(parent);
if (
parent.subject?.local_path !== parsed.value.target.path ||
parent.subject.digest.sha256 !== parsed.value.target.sha256
sourceSubject?.local_path !== parsed.value.target.path ||
sourceSubject.digest.sha256 !== parsed.value.target.sha256
)
return err(
analysisViewInputError([
+5 -2
View File
@@ -20,7 +20,10 @@ import {
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
createAnalysisViewResultReader,
} from "./analysisViewParent.js";
import { selectViewRows } from "./selectViewRows.js";
const count = z.number().int().nonnegative();
@@ -123,7 +126,7 @@ export const projectRustBinaryView = (
const parsed = parseReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const subject = parent.subject;
const subject = analysisViewSourceSubject(parent);
if (
subject == null ||
subject.local_path !== report.artifact.path ||
@@ -3,7 +3,10 @@ import { analysisViewInputError } from "./analysisViewErrors.js";
import { createHash } from "node:crypto";
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import {
analysisViewSourceSubject,
parseAnalysisViewResult,
} from "./analysisViewParent.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import {
@@ -422,11 +425,12 @@ const parseCaptureParent = (
const parsed = parseAnalysisViewResult(captureParser, parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const sourceSubject = analysisViewSourceSubject(parent);
if (
report.artifact.sha256 !== null &&
(parent.subject == null ||
parent.subject.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256)
(sourceSubject == null ||
sourceSubject.local_path !== report.artifact.path ||
sourceSubject.digest.sha256 !== report.artifact.sha256)
)
return err(
analysisViewInputError([