From 3eb502ae5d86b3f07210bad97a7190691620cd0c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:18:22 +0800 Subject: [PATCH] fix(javascript)!: bind runtime identities to capture evidence (#10063) fix(javascript)!: bind runtime graph identities to capture Evidence --- ...ScriptRuntimeReconciliationService.test.ts | 199 ++++++++++++++++++ .../javascriptApplicationEvidenceSchemas.ts | 1 + .../javascriptApplicationGraph.fixture.ts | 3 +- .../javascriptApplicationGraph.test.ts | 46 ++++ .../javascript/javascriptApplicationGraph.ts | 1 + .../javascriptRuntimeReconciliationGraph.ts | 4 +- .../javascriptRuntimeReconciliationRuntime.ts | 8 +- 7 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/application/javascript/JavaScriptRuntimeReconciliationService.test.ts diff --git a/src/application/javascript/JavaScriptRuntimeReconciliationService.test.ts b/src/application/javascript/JavaScriptRuntimeReconciliationService.test.ts new file mode 100644 index 0000000000..429aac3a00 --- /dev/null +++ b/src/application/javascript/JavaScriptRuntimeReconciliationService.test.ts @@ -0,0 +1,199 @@ +import { expect, it } from "vitest"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../domain/evidence.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { electronPageInspectionSchema } from "../../domain/javascript/electronObservation.js"; +import { + electronRuntimeEvidence, + SOURCE, +} from "../../../tests/fixtures/javascriptRuntimeReconciliation.js"; +import { reconcileJavaScriptRuntimeEvidence } from "./JavaScriptRuntimeReconciliationService.js"; + +const reconcile = (observations: readonly Evidence[]) => { + const result = reconcileJavaScriptRuntimeEvidence({ + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: observations, + }); + if (!result.ok) throw result.error; + const evidence = parseEvidence(result.value.evidence); + return { + evidence, + sources: result.value.sources, + result: javascriptRuntimeReconciliationResultSchema.parse( + evidence.normalized_result, + ), + }; +}; + +it("keeps equal runtime payloads at distinct selected endpoints separate through service graph Evidence", () => { + const first = electronRuntimeEvidence("/synthetic", SOURCE); + const second = createEvidence(undefined, first.provider, { + predicateType: first.predicate_type, + operation: first.operation, + parameters: { ...first.parameters, cdp_endpoint: "http://127.0.0.1:9444" }, + confidence: first.confidence, + authority: first.authority, + result: first.normalized_result, + }); + const observed = reconcile([first, second]); + expect(observed.result.graph.coverage).toMatchObject({ + status: "complete", + truncated: false, + omitted_count: 0, + }); + const runtime = observed.result.graph.nodes.filter( + ({ identity }) => identity.strategy === "runtime-instance", + ); + expect(runtime).toHaveLength(8); + expect(new Set(runtime.map(({ node_id }) => node_id)).size).toBe(8); + expect( + new Set( + runtime.map(({ identity }) => + identity.strategy === "runtime-instance" ? identity.capture_sha256 : "", + ), + ).size, + ).toBe(1); + for (const node of runtime) { + if (node.identity.strategy !== "runtime-instance") + throw new Error("Expected runtime instance"); + expect([first.evidence_id, second.evidence_id]).toContain( + node.identity.capture_evidence_id, + ); + for (const observation of node.observations) + expect(observation.evidence.evidence_ids).toContain( + node.identity.capture_evidence_id, + ); + } + expect(observed.evidence.parameters.runtime_evidence_ids).toEqual([ + first.evidence_id, + second.evidence_id, + ]); + expect(observed.sources).toContainEqual(first); + expect(observed.sources).toContainEqual(second); +}); + +it("preserves attach-limited frame and worker coverage through service graph Evidence", () => { + const first = electronRuntimeEvidence("/synthetic", SOURCE); + const inspection = electronPageInspectionSchema.parse( + first.normalized_result, + ); + const capture = createEvidence(undefined, first.provider, { + predicateType: first.predicate_type, + operation: first.operation, + parameters: first.parameters, + confidence: first.confidence, + authority: first.authority, + result: { + ...inspection, + completeness: { + ...inspection.completeness, + status: "attach_limited", + conditions: ["attach_limited"], + attach_limited_sections: ["frames", "workers"], + }, + }, + }); + const { result } = reconcile([capture]); + const runtime = result.graph.nodes.filter( + ({ identity }) => identity.strategy === "runtime-instance", + ); + expect(runtime).toHaveLength(4); + for (const node of runtime) { + const partial = node.kind === "frame" || node.kind === "worker"; + for (const observation of node.observations) + expect(observation.evidence.coverage).toMatchObject({ + status: partial ? "partial" : "complete", + truncated: false, + omitted_count: partial ? null : 0, + }); + for (const edge of result.graph.edges.filter( + ({ target_node_id }) => target_node_id === node.node_id, + )) + if (edge.evidence.authority === "passive-cdp-runtime") + expect(edge.evidence.coverage).toMatchObject({ + status: partial ? "partial" : "complete", + truncated: false, + omitted_count: partial ? null : 0, + }); + } + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: false, + omitted_count: null, + }); +}); + +it("preserves unresolved Inspector target location reason through service Evidence", () => { + const evidence = createEvidence( + undefined, + { + id: "rea-v8-inspector", + name: "REA passive Node/Electron V8 Inspector provider", + version: "1", + }, + { + predicateType: "rea.javascript-runtime-observation", + operation: "observe_javascript_runtime", + parameters: { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "node-main", + observation_ms: 0, + }, + authority: "external-service", + confidence: "observed", + result: { + runtime: { + product: "node/fixture", + protocol_version: "1.1", + v8_version: null, + }, + target: { + target_id: "node-main", + protocol_type: "node", + attached: null, + location: { + kind: "unresolved", + reported_url: "file:///operator-selected.js", + reason: "location-authorization-not-attempted", + }, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 0, + events_observed: 0, + events_retained: 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: [], + observed_total: 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: [], + limitations: [], + }, + }, + ); + const projected = reconcile([evidence]); + expect( + projected.result.graph.nodes.find( + ({ kind, identity }) => + kind === "target" && identity.strategy === "runtime-instance", + )?.observations[0]?.properties, + ).toMatchObject({ + reported_url: "file:///operator-selected.js", + location_kind: "unresolved", + location_reason: "location-authorization-not-attempted", + }); +}); diff --git a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts index 6ee5ac8bab..57a7a14c76 100644 --- a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts +++ b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts @@ -446,6 +446,7 @@ export const applicationNodeIdentitySchema = z.discriminatedUnion("strategy", [ z.strictObject({ strategy: z.literal("runtime-instance"), stability: z.literal("capture-only"), + capture_evidence_id: prefixedDigestSchema("ev"), capture_sha256: digestSchema, runtime_key: boundedTextSchema, }), diff --git a/src/domain/javascript/javascriptApplicationGraph.fixture.ts b/src/domain/javascript/javascriptApplicationGraph.fixture.ts index eada605c37..792fb0ba7f 100644 --- a/src/domain/javascript/javascriptApplicationGraph.fixture.ts +++ b/src/domain/javascript/javascriptApplicationGraph.fixture.ts @@ -111,7 +111,7 @@ export const runtimeEvidence = ( extractor: extractor("observe-passive-cdp"), coverage: completeCoverage, limitations: ["Only passive CDP metadata was observed."], - evidence_ids: [], + evidence_ids: [`ev_${"c".repeat(64)}`], }); type FixtureNode = ReturnType; @@ -271,6 +271,7 @@ const buildNativeRuntimeNodes = (): FixtureNode[] => [ { strategy: "runtime-instance", stability: "capture-only", + capture_evidence_id: `ev_${"c".repeat(64)}`, capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, runtime_key: "script-preload", }, diff --git a/src/domain/javascript/javascriptApplicationGraph.test.ts b/src/domain/javascript/javascriptApplicationGraph.test.ts index 082aa41fb7..3f68602e7b 100644 --- a/src/domain/javascript/javascriptApplicationGraph.test.ts +++ b/src/domain/javascript/javascriptApplicationGraph.test.ts @@ -411,6 +411,7 @@ describe("JavaScript Application Graph: identity and evidence", () => { identity: { strategy: "runtime-instance", stability: "capture-only", + capture_evidence_id: `ev_${"c".repeat(64)}`, capture_sha256: "6".repeat(64), runtime_key: "script-preload", }, @@ -811,6 +812,50 @@ describe("JavaScript Application Graph: duplicate and orphan rejection", () => { }); }); +it("rejects runtime identities without an explicit capture Evidence identifier", () => { + expect(() => + createJavaScriptApplicationNode({ + kind: "runtime-script-instance", + identity: { + strategy: "runtime-instance", + stability: "capture-only", + capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, + runtime_key: "script-preload", + }, + observations: [ + { + label: null, + properties: {}, + evidence: runtimeEvidence("script-preload"), + }, + ], + }), + ).toThrow(/capture_evidence_id/u); +}); + +it("requires runtime identities to retain their capture Evidence provenance", () => { + const runtimeWithUnrelatedEvidence = createJavaScriptApplicationNode({ + kind: "runtime-script-instance", + identity: { + strategy: "runtime-instance", + stability: "capture-only", + capture_evidence_id: `ev_${"d".repeat(64)}`, + capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, + runtime_key: "script-preload", + }, + observations: [ + { + label: null, + properties: {}, + evidence: runtimeEvidence("script-preload"), + }, + ], + }); + expect(() => graphForNode(runtimeWithUnrelatedEvidence)).toThrow( + /identity is not supported/u, + ); +}); + describe("JavaScript Application Graph: identity strategy requirements", () => { it("requires every identity strategy to have compatible supporting evidence", () => { const mismatchedPath = createJavaScriptApplicationNode({ @@ -841,6 +886,7 @@ describe("JavaScript Application Graph: identity strategy requirements", () => { identity: { strategy: "runtime-instance", stability: "capture-only", + capture_evidence_id: `ev_${"c".repeat(64)}`, capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, runtime_key: "script-preload", }, diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts index 93b8fa97d6..3cdaa82420 100644 --- a/src/domain/javascript/javascriptApplicationGraph.ts +++ b/src/domain/javascript/javascriptApplicationGraph.ts @@ -245,6 +245,7 @@ const observationMatchesIdentity = ( return false; const location = evidence.location.value; return ( + evidence.evidence_ids.includes(identity.capture_evidence_id) && location.capture_sha256 === identity.capture_sha256 && [location.target_key, location.frame_key, location.script_key].includes( identity.runtime_key, diff --git a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts index 1d915def3f..d1703ad3cd 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts @@ -137,7 +137,9 @@ const graphCoverageFacts = ( inspection.completeness.conditions.includes("truncated"), ); const captureHasUnknownOmissions = input.captures.some( - ({ inspection }) => inspection.completeness.unavailable_sections.length > 0, + ({ inspection }) => + inspection.completeness.attach_limited_sections.length > 0 || + inspection.completeness.unavailable_sections.length > 0, ); const inputTruncated = input.layers.some(({ graph }) => graph.coverage.truncated) || diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index 613d47175c..3fd07c4eee 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -102,11 +102,11 @@ const targetEntity = ( label: target.title, properties: { runtime_type: target.type, - ...(location.kind === "unresolved" + ...("unresolved_location" in target ? { reported_url: location.value, location_kind: "unresolved", - location_reason: "unverifiable-file-location", + location_reason: target.unresolved_location.reason, } : { location: location.value }), attached: target.attached, @@ -276,6 +276,7 @@ const runtimeEntity = ( identity: { strategy: "runtime-instance", stability: "capture-only", + capture_evidence_id: capture.evidence.evidence_id, capture_sha256: capture.captureSha256, runtime_key: input.runtimeKey, }, @@ -420,6 +421,9 @@ const runtimeSectionComplete = ( if (section === "target") return true; if (section === "scripts") return capture.scriptsCompleteWithinScope; return ( + !capture.inspection.completeness.attach_limited_sections.includes( + section, + ) && !capture.inspection.completeness.truncated_sections.includes(section) && !capture.inspection.completeness.unavailable_sections.includes(section) );