fix(analysis-view): unify inert inline Evidence admission

Share inline Evidence admission before authentication across analysis views.
Reject executable object behavior before reflection, inspect arrays
incrementally, preserve inert JSON-omitted metadata and existing budgets.
Keep precise error locations with one bounded path stack.

Validated base failures, 48 source tests, 71 related public-boundary tests,
27 final CLI/MCP tests and eleven calls against a genuine Chromium HAR;
source identity, selected output and capacity accounting remain preserved.

Co-authored-by: Zhong-fan <205188959+Zhong-fan@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 03:13:28 +08:00
1 parent 75104040df
commit 11b747f29d
5 files changed
+409 -170

No files matched your search

@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js";
import { functionDossierSchema } from "../../domain/hopperValues.js";
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
import {
@@ -432,45 +433,284 @@ it("refuses an object-dense borrowed capture before making a full schema clone",
expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed);
});
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) {
it.each(["summary", "capture", "api-surface"] as const)(
"rejects inline accessors before parsing a %s view",
(kind) => {
let accessed = false;
const evidence = {
get normalized_result() {
accessed = true;
throw new Error("Evidence accessor must not be invoked");
throw new Error("Evidence must be admitted before parsing");
},
};
const result = inspectAnalysisView({
const input = {
source: { kind: "inline", evidence },
view,
});
expect(result).toMatchObject({
view: { kind },
};
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" },
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "evidence", "normalized_result"] }],
},
});
expect(accessed).toBe(false);
}
const parent = analysisViewCaptureEvidence();
for (const view of views)
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view,
source: { kind: "inline", evidence: analysisViewCaptureEvidence() },
view: { kind },
}).ok,
).toBe(true);
},
);
it.each(["summary", "capture", "api-surface"] as const)(
"rejects nested, hidden and inherited inline accessors for %s",
(kind) => {
const parent = analysisViewCaptureEvidence();
let accessed = false;
const getter = () => {
accessed = true;
throw new Error("Inline accessors must remain inert");
};
const nested = {
get value() {
return getter();
},
};
const hidden = Object.defineProperty({}, "value", { get: getter });
const array = Object.defineProperty([null], 0, { get: getter });
const inherited: unknown = Object.create({
get value() {
return getter();
},
});
const cyclic: Record<string, unknown> = {};
cyclic["self"] = cyclic;
const cases: { data: unknown; path: (string | number)[] }[] = [
{ data: nested, path: ["value"] },
{ data: hidden, path: ["value"] },
{ data: array, path: [0] },
{ data: inherited, path: [] },
{ data: cyclic, path: ["self"] },
];
for (const { data, path } of cases) {
// A complete envelope reaches payload parsing and Evidence authentication.
const input = {
source: {
kind: "inline" as const,
evidence: { ...parent, raw_result: data },
},
view: { kind },
};
for (const result of [
inspectAnalysisView(input),
inspectAnalysisViewValidated(
inspectAnalysisViewInputSchema.parse(input),
),
])
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "evidence", "raw_result", ...path] }],
},
});
}
expect(accessed).toBe(false);
},
);
it.each([
["own map", "map", false],
["own iterator", Symbol.iterator, false],
["own constructor", "constructor", false],
["inherited map", "map", true],
["inherited iterator", Symbol.iterator, true],
] as const)(
"rejects array %s accessors without invoking them",
(_name, key, inherited) => {
const parent = analysisViewCaptureEvidence();
let accessed = false;
const array: unknown[] = [];
const owner: object = inherited ? Object.create(Array.prototype) : array;
Object.defineProperty(owner, key, {
get() {
accessed = true;
throw new Error("Array behavior must remain inert");
},
});
if (inherited) Object.setPrototypeOf(array, owner);
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: array } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(accessed).toBe(false);
},
);
it("rejects callable array overrides and subclass species before parsing", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const callback = () => {
invoked = true;
throw new Error("Array callbacks must remain inert");
};
class CustomArray extends Array<unknown> {
static override get [Symbol.species](): ArrayConstructor {
return callback();
}
}
for (const array of [
Object.defineProperty([], "map", { value: callback }),
Object.defineProperty([], Symbol.iterator, { value: callback }),
new CustomArray(),
])
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: array } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(invoked).toBe(false);
});
it.each(["summary", "capture", "api-surface"] as const)(
"preserves ordinary inline Evidence for %s",
(kind) => {
const parent = analysisViewCaptureEvidence();
const before = JSON.stringify(parent);
const result = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value)).toEqual(result.value);
expect(result.value.evidence_links).toEqual([parent.evidence_id]);
expect(result.value.normalized_result).toMatchObject({
kind,
parent_evidence_id: parent.evidence_id,
});
expect(JSON.stringify(parent)).toBe(before);
expect(Object.isFrozen(parent)).toBe(false);
},
);
it("rejects executable constructor values before schema plain-object checks", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const constructor = () => undefined;
Object.defineProperty(constructor, "prototype", {
get() {
invoked = true;
throw new Error("Constructor metadata must remain inert");
},
});
for (const kind of ["summary", "capture", "api-surface"] as const) {
expect(
inspectAnalysisView({
source: {
kind: "inline",
evidence: { ...parent, raw_result: { constructor } },
},
view: { kind },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
}
expect(invoked).toBe(false);
});
it("rejects inline proxy values before reflection or parsing can run traps", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const trap = () => {
invoked = true;
throw new Error("Proxy behavior must remain inert");
};
const revoked = Proxy.revocable([], {});
revoked.revoke();
for (const value of [
new Proxy({}, { getPrototypeOf: trap, ownKeys: trap, get: trap }),
new Proxy(() => undefined, { get: trap }),
revoked.proxy,
])
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: value } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(invoked).toBe(false);
});
it("keeps ignored array metadata inert through parsing and projection", () => {
const capture = analysisViewCaptureFixture();
const parent = createEvidence(
{
path: capture.artifact.path,
sha256: capture.artifact.sha256!,
format: "file",
},
{ id: "fixture", name: "fixture", version: "1" },
{
operation: "inspect_web_network_capture",
parameters: { sensitive_value_count: 0 },
result: capture,
rawResult: [],
},
);
if (!Array.isArray(parent.raw_result)) throw new Error("Expected raw array");
const array = parent.raw_result;
let invoked = false;
const getter = () => {
invoked = true;
throw new Error("Ignored array metadata must remain inert");
};
for (let index = 0; index < 10_000; index += 1)
Object.defineProperty(array, `metadata-${index}`, { get: getter });
Object.defineProperty(array, Symbol("metadata"), { get: getter });
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([parent.evidence_id]);
}
expect(invoked).toBe(false);
expect(parseEvidence(parent).raw_result).toEqual([]);
});
it("keeps inline-envelope admission live alongside capture validation and grouping", () => {
// Each clone alone fits; both graphs coexist while the view is projected.
const capture = analysisViewObjectDenseCapture(40_000);
const parent = analysisViewCaptureEvidence(capture);
for (const kind of ["summary", "capture"] as const) {
const projected = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!projected.ok) throw projected.error;
expect(projected.value.evidence_links).toEqual([parent.evidence_id]);
}
const retained = inspectAnalysisView(
{
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
@@ -6,10 +6,7 @@ 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,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
@@ -27,6 +24,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js";
/** Authenticate a selected-view request and project already completed Evidence. */
export const inspectAnalysisView = (
@@ -58,8 +56,11 @@ export const inspectAnalysisViewValidated = (
input.view.include_query_parameter_names === true,
)
: undefined;
if (input.source.kind === "inline" && accumulator !== undefined) {
const admitted = accumulator.admitInlineEvidence(input.source.evidence);
if (input.source.kind === "inline") {
const admitted =
accumulator === undefined
? admitAnalysisViewData(input.source.evidence, true)
: accumulator.admitInlineEvidence(input.source.evidence);
if (!admitted.ok) return admitted;
}
const resolved = resolveAnalysisViewSource(input.source, lookup);
@@ -165,18 +166,6 @@ const resolveAnalysisViewSource = (
): Result<Evidence, AnalysisError> => {
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) {
@@ -203,53 +192,6 @@ 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<object>();
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,
@@ -1,4 +1,4 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js";
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
@@ -73,6 +73,43 @@ it("admits the capture schema clone and grouping against the same budget", () =>
expect(accumulator.page(0, 1).groups).toEqual([]);
});
it.each(["admitCaptureClone", "admitInlineEvidence"] as const)(
"%s refuses dense array expansion without enumerating its keys",
(method) => {
const array = Array.from({ length: 100_000 }, () => null);
let enumerated = false;
let inspected = 0;
const ownNames = Object.getOwnPropertyNames;
const ownDescriptor = Object.getOwnPropertyDescriptor;
const keys = vi
.spyOn(Object, "getOwnPropertyNames")
.mockImplementation((value) => {
if (value === array) enumerated = true;
return ownNames(value);
});
const descriptors = vi
.spyOn(Object, "getOwnPropertyDescriptor")
.mockImplementation((value, key) => {
if (value === array) inspected += 1;
return ownDescriptor(value, key);
});
try {
expect(
new WebNetworkApiSurfaceAccumulator(4096)[method](array),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
});
expect(enumerated).toBe(false);
expect(inspected).toBeGreaterThan(0);
expect(inspected).toBeLessThan(16);
} finally {
keys.mockRestore();
descriptors.mockRestore();
}
},
);
it("charges non-enumerable fields that the capture schema would clone", () => {
const value = Object.defineProperty({}, "records", {
value: Array.from({ length: 100 }, () => ({ reported: {} })),
@@ -1,8 +1,5 @@
import { z } from "zod";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../analysisErrorCore.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
@@ -10,7 +7,7 @@ import {
parseUrlQueryParameterNames,
} from "../recordedTransportUrl.js";
import { err, ok, type Result } from "../result.js";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
type WebNetworkCaptureRecord,
@@ -89,9 +86,6 @@ const ENTRY_BYTES = 128;
const RECORD_SCRATCH_BYTES = 512;
const SEGMENT_SCRATCH_BYTES = 512;
const CAPTURE_CONTAINER_BYTES = 512;
// Covers cloned member slots and per-member validation/entries/map scratch.
// Scalar strings remain shared references, so their text is not copied here.
const CAPTURE_MEMBER_BYTES = 256;
const textBytes = (length: number): number => 32 + length * 14;
const segmentRule = (value: string): Rule | null => {
@@ -156,87 +150,15 @@ export class WebNetworkApiSurfaceAccumulator {
/** Reserve schema output and validation scratch while the borrowed parent stays live. */
admitCaptureClone(value: unknown): Result<void, AnalysisError> {
return this.#admitCaptureValue(value, new Set(), 0, false);
return admitAnalysisViewData(value, false, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
);
}
/** Admit the complete portable envelope before cloning or authenticating it. */
admitInlineEvidence(value: unknown): Result<void, AnalysisError> {
return this.#admitCaptureValue(value, new Set(), 0, true);
}
#admitCaptureValue(
value: unknown,
ancestors: Set<object>,
depth: number,
inline: boolean,
): Result<void, AnalysisError> {
// Capture records add three levels before reported; inline Evidence adds one.
if (
depth > MAX_JSON_DEPTH + (inline ? 4 : 3) ||
(typeof value === "object" && value !== null && ancestors.has(value))
)
return this.#invalidCapture();
if (!this.#retain(CAPTURE_MEMBER_BYTES)) return this.#overBudget();
// Evidence authentication encodes strings and object keys for hashing.
// Keep that allowance live alongside the envelope clone and projection.
if (
inline &&
typeof value === "string" &&
!this.#retain(textBytes(value.length))
)
return this.#overBudget();
if (typeof value !== "object" || value === null) return ok(undefined);
if (!this.#retain(CAPTURE_CONTAINER_BYTES)) return this.#overBudget();
ancestors.add(value);
// Walk depth-first without building a second capture graph. Member charges
// include key enumeration; aliases count each time the schema copies them.
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, index);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return this.#invalidCapture();
const admitted = this.#admitCaptureValue(
descriptor.value,
ancestors,
depth + 1,
inline,
);
if (!admitted.ok) return admitted;
}
} else {
const prototype: unknown = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null)
return this.#invalidCapture();
// Zod reads named fields even when they are non-enumerable.
for (const key of Object.getOwnPropertyNames(value)) {
if (inline && !this.#retain(textBytes(key.length)))
return this.#overBudget();
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return this.#invalidCapture();
const admitted = this.#admitCaptureValue(
descriptor.value,
ancestors,
depth + 1,
inline,
);
if (!admitted.ok) return admitted;
}
}
ancestors.delete(value);
return ok(undefined);
}
#invalidCapture(): Result<never, AnalysisError> {
return err(
new AnalysisInputError("inspect_analysis_view", undefined, [
{
path: ["source"],
reason: "invalid_value",
message:
"Parent capture must contain bounded ordinary JSON data without cycles or accessors.",
},
]),
return admitAnalysisViewData(value, true, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
);
}
@@ -0,0 +1,98 @@
import { isProxy } from "node:util/types";
import { AnalysisInputError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
// Covers cloned member slots and per-member validation/entries/map scratch.
const MEMBER_BYTES = 256;
const CONTAINER_BYTES = 512;
const textBytes = (length: number): number => 32 + length * 14;
/** Inspect borrowed data without invoking accessors; only API-surface reserves bytes. */
export const admitAnalysisViewData = (
value: unknown,
inline: boolean,
reserve?: (bytes: number) => Result<void, AnalysisError>,
): Result<void, AnalysisError> => {
const ancestors = new Set<object>();
// A single bounded stack preserves diagnostics without per-node path copies.
const path: (string | number)[] = inline
? ["source", "evidence"]
: ["source"];
const invalid = (): Result<never, AnalysisError> =>
err(
new AnalysisInputError("inspect_analysis_view", undefined, [
{
path: [...path],
reason: "invalid_value",
message:
"Parent data must contain bounded ordinary JSON data without cycles or accessors.",
},
]),
);
const visit = (item: unknown, depth: number): Result<void, AnalysisError> => {
// Proxy reflection and callable constructor metadata can execute caller code.
if (isProxy(item) || typeof item === "function") return invalid();
// Capture records add three levels before reported; inline Evidence adds one.
if (
depth > MAX_JSON_DEPTH + (inline ? 4 : 3) ||
(typeof item === "object" && item !== null && ancestors.has(item))
)
return invalid();
const member = reserve?.(
MEMBER_BYTES +
(inline && typeof item === "string" ? textBytes(item.length) : 0) +
(typeof item === "object" && item !== null ? CONTAINER_BYTES : 0),
);
if (member !== undefined && !member.ok) return member;
if (typeof item !== "object" || item === null) return ok(undefined);
ancestors.add(item);
const child = (key: string | number): Result<void, AnalysisError> => {
path.push(key);
const descriptor = Object.getOwnPropertyDescriptor(item, key);
const admitted =
descriptor === undefined || !Object.hasOwn(descriptor, "value")
? invalid()
: visit(descriptor.value, depth + 1);
path.pop();
return admitted;
};
// Walk depth-first without copying the graph. Aliases are examined each time
// the schema copies them, so byte reservations retain the same accounting.
if (Array.isArray(item)) {
// JSON validation iterates arrays and cloning calls map (which reads
// constructor/species). Reject those traversal overrides without building
// an input-sized key list. Other array metadata is omitted by JSON cloning.
if (
Object.getPrototypeOf(item) !== Array.prototype ||
Object.hasOwn(item, Symbol.iterator) ||
Object.hasOwn(item, "map") ||
Object.hasOwn(item, "constructor")
)
return invalid();
for (let index = 0; index < item.length; index += 1) {
const admitted = child(index);
if (!admitted.ok) return admitted;
}
} else {
const prototype: unknown = Object.getPrototypeOf(item);
if (prototype !== Object.prototype && prototype !== null)
return invalid();
// Zod reads named fields even when they are non-enumerable.
for (const key of Object.getOwnPropertyNames(item)) {
// Authentication hashes strings and keys alongside the schema clone.
const keyAdmission = inline
? reserve?.(textBytes(key.length))
: undefined;
if (keyAdmission !== undefined && !keyAdmission.ok) return keyAdmission;
const admitted = child(key);
if (!admitted.ok) return admitted;
}
}
ancestors.delete(item);
return ok(undefined);
};
return visit(value, 0);
};