diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 10258d3f90..b4421f80ae 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -24,7 +24,6 @@ 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 { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js"; it.each(["/fixtures/unknown.exe", ""])( "preserves recorded empty native identity fields: path=%j", @@ -433,26 +432,39 @@ it("refuses an object-dense borrowed capture before making a full schema clone", expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed); }); -it("borrows inline Evidence until admission without invoking nested accessors", () => { - let accessed = false; - const evidence = { - get normalized_result() { - accessed = true; - throw new Error("Evidence must be admitted before parsing"); - }, - }; - const input = { - source: { kind: "inline", evidence }, - view: { kind: "api-surface" }, - }; - const parsed = inspectAnalysisViewInputSchema.parse(input); - if (parsed.source.kind !== "inline") throw new Error("Expected inline input"); - expect(parsed.source.evidence).toBe(evidence); - expect(inspectAnalysisView(input)).toMatchObject({ - ok: false, - error: { _tag: "AnalysisInputError" }, - }); - expect(accessed).toBe(false); +it("rejects accessor-backed inline Evidence before all view projections", () => { + const views = [ + { kind: "summary" }, + { kind: "capture" }, + { kind: "api-surface" }, + ] as const; + for (const view of views) { + let accessed = false; + const evidence = { + get normalized_result() { + accessed = true; + throw new Error("Evidence accessor must not be invoked"); + }, + }; + const result = inspectAnalysisView({ + source: { kind: "inline", evidence }, + view, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(accessed).toBe(false); + } + + const parent = analysisViewCaptureEvidence(); + for (const view of views) + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view, + }).ok, + ).toBe(true); }); it("keeps inline-envelope admission live alongside capture validation and grouping", () => { diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index f12033ee07..4e68dd868e 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -5,7 +5,10 @@ import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { @@ -159,6 +162,18 @@ const resolveAnalysisViewSource = ( ): Result => { if (source.kind === "retained-evidence") return resolveEvidenceInput(source, lookup); + const invalidDataPath = inlineEvidenceInvalidDataPath(source.evidence); + if (invalidDataPath !== undefined) + return err( + new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, [ + { + path: ["source", "evidence", ...invalidDataPath], + reason: "invalid_type", + message: + "Inline Evidence must contain plain JSON data properties without accessors or cycles.", + }, + ]), + ); try { return ok(parseEvidence(source.evidence)); } catch (cause: unknown) { @@ -185,6 +200,53 @@ const resolveAnalysisViewSource = ( } }; +/** Reject non-JSON object behavior before Zod reads caller-owned Evidence. */ +const inlineEvidenceInvalidDataPath = ( + root: unknown, +): readonly (string | number)[] | undefined => { + const active = new Set(); + const pending: { + readonly value: unknown; + readonly path: readonly (string | number)[]; + readonly exit?: object; + }[] = [{ value: root, path: [] }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) continue; + if (current.exit !== undefined) { + active.delete(current.exit); + continue; + } + const { value, path } = current; + if (typeof value !== "object" || value === null) continue; + const isArray = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ( + (isArray && prototype !== Array.prototype) || + (!isArray && prototype !== Object.prototype && prototype !== null) || + active.has(value) + ) + return path; + + active.add(value); + pending.push({ value, path, exit: value }); + const keys = Object.getOwnPropertyNames(value); + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index]; + if (key === undefined) continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + const childPath = [ + ...path, + isArray && /^(0|[1-9]\d*)$/u.test(key) ? Number(key) : key, + ]; + if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) + return childPath; + pending.push({ value: descriptor.value, path: childPath }); + } + } + return undefined; +}; + const createAnalysisViewEvidence = ( input: InspectAnalysisViewInput, parent: Evidence,