From 73b09f4c5c511ce80baa0c89717181bbe0ecba8f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:09:16 +0800 Subject: [PATCH] fix(managed): bind inspection evidence to artifact identity --- docs/managed-code-analysis.md | 6 ++ src/domain/managed/managedApplicationGraph.ts | 25 +++---- .../managed/managedInspectionEvidence.ts | 26 +++++++ .../managed/managedMemberComparisonMatch.ts | 14 ++-- .../managed/managedNativeVerification.ts | 26 +++---- .../managed/managedMemberComparison.test.ts | 70 ++++++++++++++++++- .../managedApplicationGraph.test.ts | 42 +++++++++++ .../managed/managedNativeVerification.test.ts | 48 +++++++++++++ 8 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 src/domain/managed/managedInspectionEvidence.ts diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index b0a0df4c04..92bc230609 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -137,6 +137,12 @@ function implements a specific managed method. ## Evidence record shape +Member comparison, application graph projection, and managed/native verification +authenticate supplied inspection Evidence and check that any subject SHA-256 +matches the normalized artifact SHA-256. A valid Evidence ID alone does not +establish that those two identities agree. Evidence without a subject remains +usable, with no subject digest available to cross-check. + Every planned operation returns a provider result and Evidence with four commitment groups: diff --git a/src/domain/managed/managedApplicationGraph.ts b/src/domain/managed/managedApplicationGraph.ts index 8158e205cf..9074bc40cb 100644 --- a/src/domain/managed/managedApplicationGraph.ts +++ b/src/domain/managed/managedApplicationGraph.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { evidenceSchema, parseEvidence, type Evidence } from "../evidence.js"; +import { evidenceSchema, type Evidence } from "../evidence.js"; import { createJavaScriptApplicationGraph, javascriptApplicationGraphSchema, @@ -32,6 +32,7 @@ import { } from "./managedApplicationGraphNodes.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; +import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const textSchema = z.string().min(1); @@ -194,7 +195,7 @@ const parseManagedInputs = ( artifact: input.managed_artifact === undefined ? null - : parseManagedEvidence( + : parseManagedInspectionEvidence( input.managed_artifact, "inspect_managed_artifact", managedArtifactInspectionSchema, @@ -202,7 +203,7 @@ const parseManagedInputs = ( members: input.managed_members === undefined ? null - : parseManagedEvidence( + : parseManagedInspectionEvidence( input.managed_members, "inspect_managed_members", managedMemberInspectionSchema, @@ -210,24 +211,13 @@ const parseManagedInputs = ( boundaries: input.managed_native_boundaries === undefined ? null - : parseManagedEvidence( + : parseManagedInspectionEvidence( input.managed_native_boundaries, "inspect_managed_native_boundaries", managedNativeBoundaryInspectionSchema, ), }); -const parseManagedEvidence = ( - rawEvidence: unknown, - operation: string, - schema: z.ZodType, -): { readonly evidence: Evidence; readonly result: Result } => { - const evidence = parseEvidence(rawEvidence); - if (evidence.operation !== operation) - throw new TypeError(`Evidence operation is not ${operation}`); - return { evidence, result: schema.parse(evidence.normalized_result) }; -}; - const chooseArtifact = ( parsed: ParsedManagedGraphInput, ): { readonly sha256: string; readonly path: string } => { @@ -272,6 +262,11 @@ const projectionLimitations = ( "Managed member Evidence was not supplied; type, method, and field nodes are absent.", ] : []), + ...(parsed.artifact === null + ? [ + "Managed artifact Evidence was not supplied; assembly identity observations are absent.", + ] + : []), ...(parsed.boundaries === null ? [ "Managed native-boundary Evidence was not supplied; P/Invoke and native-implementation nodes are absent.", diff --git a/src/domain/managed/managedInspectionEvidence.ts b/src/domain/managed/managedInspectionEvidence.ts new file mode 100644 index 0000000000..3bd35ea7b7 --- /dev/null +++ b/src/domain/managed/managedInspectionEvidence.ts @@ -0,0 +1,26 @@ +import { parseEvidence, type Evidence } from "../evidence.js"; +import type { z } from "zod"; + +/** + * Parse managed inspection Evidence and bind its artifact to any available subject. + * A null subject remains valid and means there is no digest to cross-check. + */ +export const parseManagedInspectionEvidence = < + Result extends { readonly artifact: { readonly sha256: string } }, +>( + rawEvidence: unknown, + operation: string, + schema: z.ZodType, +): { readonly evidence: Evidence; readonly result: Result } => { + const evidence = parseEvidence(rawEvidence); + if (evidence.operation !== operation) + throw new TypeError(`Evidence operation is not ${operation}`); + const result = schema.parse(evidence.normalized_result); + const artifactSha256 = result.artifact.sha256; + const subjectSha256 = evidence.subject?.digest.sha256; + if (subjectSha256 !== undefined && artifactSha256 !== subjectSha256) + throw new TypeError( + `Managed Evidence ${operation} (${evidence.evidence_id}) subject SHA-256 ${subjectSha256} does not match normalized artifact SHA-256 ${artifactSha256}`, + ); + return { evidence, result }; +}; diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index c9fd3aa43f..229263c4f1 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -2,11 +2,11 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; -import { parseEvidence } from "../evidence.js"; import { managedMemberInspectionSchema, type ManagedMemberInspection, } from "./managedArtifact.js"; +import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js"; import type { ManagedMemberComparisonResult, ManagedMemberComparisonSide, @@ -275,11 +275,13 @@ export const parseManagedMemberEvidence = ( readonly evidenceId: string; readonly result: ManagedMemberInspection; } => { - const parsed = parseEvidence(evidence); - if (parsed.operation !== "inspect_managed_members") - throw new TypeError("Evidence operation is not inspect_managed_members"); + const parsed = parseManagedInspectionEvidence( + evidence, + "inspect_managed_members", + managedMemberInspectionSchema, + ); return { - evidenceId: parsed.evidence_id, - result: managedMemberInspectionSchema.parse(parsed.normalized_result), + evidenceId: parsed.evidence.evidence_id, + result: parsed.result, }; }; diff --git a/src/domain/managed/managedNativeVerification.ts b/src/domain/managed/managedNativeVerification.ts index 2263fd4120..0ba3cdef13 100644 --- a/src/domain/managed/managedNativeVerification.ts +++ b/src/domain/managed/managedNativeVerification.ts @@ -2,11 +2,12 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; -import { parseEvidence } from "../evidence.js"; +import type { Evidence } from "../evidence.js"; import { managedNativeBoundaryInspectionSchema, type ManagedNativeBoundaryInspection, } from "./managedArtifact.js"; +import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js"; import type { JsonValue } from "../jsonValue.js"; import { managedNativeVerificationResultSchema, @@ -33,25 +34,24 @@ const sha256 = (value: JsonValue): string => { return createHash("sha256").update(serialized).digest("hex"); }; -const managedBoundaryFromInput = ( +const parseManagedBoundary = ( input: ManagedNativeVerificationInput, -): ManagedNativeBoundaryInspection => { - const managedEvidence = parseEvidence(input.managed_boundaries); - if (managedEvidence.operation !== "inspect_managed_native_boundaries") - throw new TypeError( - "Evidence operation is not inspect_managed_native_boundaries", - ); - return managedNativeBoundaryInspectionSchema.parse( - managedEvidence.normalized_result, +): { + readonly evidence: Evidence; + readonly result: ManagedNativeBoundaryInspection; +} => + parseManagedInspectionEvidence( + input.managed_boundaries, + "inspect_managed_native_boundaries", + managedNativeBoundaryInspectionSchema, ); -}; /** Verify managed P/Invoke declarations against authenticated native Evidence. */ export const verifyManagedNativeBoundaries = ( input: ManagedNativeVerificationInput, ): ManagedNativeVerificationResult => { - const managed = managedBoundaryFromInput(input); - const managedEvidence = parseEvidence(input.managed_boundaries); + const { evidence: managedEvidence, result: managed } = + parseManagedBoundary(input); const native = collectNativeSymbols(input.native_observations); const verifiedPinvokes = managed.pinvoke_imports.map((item) => verifyPinvoke({ diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 2ba2eb13f0..4caf2b22ef 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -10,7 +10,10 @@ import { compareManagedMemberPaths, } from "../../../../src/application/managed/ManagedMemberComparisonService.js"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; -import { managedMemberComparisonResultSchema } from "../../../../src/domain/managed/managedMemberComparison.js"; +import { + managedMemberComparisonResultSchema, + parseManagedMemberEvidence, +} from "../../../../src/domain/managed/managedMemberComparison.js"; import { createEvidence } from "../../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { jsonValueSchema } from "../../../../src/domain/jsonValue.js"; @@ -54,6 +57,71 @@ it.each([ }, ); +describe("managed member comparison subject identity", () => { + it("rejects authenticated member Evidence whose subject digest differs from its artifact", () => { + const bytes = buildManagedPeFixture(); + const target = managedPeFixtureTarget(bytes, "/fixture/mismatch.dll"); + const inspection = inspectManagedMembersBytes(bytes, target); + const otherDigest = "b".repeat(64); + const mismatched = createEvidence( + target, + { + id: "partial-fixture", + name: "Partial fixture", + version: "1", + }, + { + operation: "inspect_managed_members", + parameters: {}, + result: jsonValueSchema.parse({ + ...inspection, + artifact: { ...inspection.artifact, sha256: otherDigest }, + identity_scope: { + ...inspection.identity_scope, + requires_artifact_sha256: otherDigest, + }, + }), + }, + ); + const aligned = createEvidence( + target, + { + id: "partial-fixture", + name: "Partial fixture", + version: "1", + }, + { + operation: "inspect_managed_members", + parameters: {}, + result: inspection, + }, + ); + const unavailableSubject = createEvidence( + undefined, + { + id: "partial-fixture", + name: "Partial fixture", + version: "1", + }, + { + operation: "inspect_managed_members", + parameters: {}, + result: inspection, + }, + ); + + expect(() => parseManagedMemberEvidence(mismatched)).toThrow( + `Managed Evidence inspect_managed_members (${mismatched.evidence_id}) subject SHA-256 ${target.sha256} does not match normalized artifact SHA-256 ${otherDigest}`, + ); + expect(parseManagedMemberEvidence(aligned).result.artifact.sha256).toBe( + target.sha256, + ); + expect( + parseManagedMemberEvidence(unavailableSubject).result.artifact.sha256, + ).toBe(target.sha256); + }); +}); + describe("managed member comparison path workflow", () => { it("preserves undecoded signatures as unknown through content-addressed partial Evidence", () => { const observe = (signature: number, path: string) => { diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index 1bc93d8a11..39d31b07bf 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -167,6 +167,48 @@ describe("managed application graph request", () => { }); describe("managed application graph coverage", () => { + it("rejects managed Evidence digest mismatch and discloses missing artifact observations", () => { + const bytes = buildManagedPeFixture(); + const binary = managedPeFixtureTarget(bytes, "/fixture/Mismatch.dll"); + const members = inspectManagedMembersBytes(bytes, binary); + const mismatched = createEvidence(binary, MANAGED_STATIC_PROVIDER, { + operation: "inspect_managed_members", + parameters: {}, + result: { + ...members, + artifact: { ...members.artifact, sha256: "b".repeat(64) }, + identity_scope: { + ...members.identity_scope, + requires_artifact_sha256: "b".repeat(64), + }, + }, + }); + const rejected = projectManagedApplicationGraphEvidence({ + managed_members: mismatched, + }); + expect(rejected.ok).toBe(false); + if (rejected.ok) throw new Error("mismatched managed Evidence accepted"); + expect(JSON.stringify(rejected.error)).toContain( + `Managed Evidence inspect_managed_members (${mismatched.evidence_id}) subject SHA-256 ${binary.sha256} does not match normalized artifact SHA-256 ${"b".repeat(64)}`, + ); + + const aligned = createEvidence(binary, MANAGED_STATIC_PROVIDER, { + operation: "inspect_managed_members", + parameters: {}, + result: members, + }); + const projected = projectManagedApplicationGraphEvidence({ + managed_members: aligned, + }); + if (!projected.ok) throw projected.error; + const result = managedApplicationGraphResultSchema.parse( + parseEvidence(projected.value).normalized_result, + ); + expect(result.limitations).toContain( + "Managed artifact Evidence was not supplied; assembly identity observations are absent.", + ); + }); + it("preserves partial parser coverage in graph and per-fact coverage", () => { const bytes = buildManagedPeFixture(); const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe"); diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 4932ae1103..626705719c 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -60,6 +60,54 @@ const nativeEvidenceWithExports = (names: readonly string[]) => { ); }; +describe("managed/native boundary Evidence identity", () => { + it("rejects managed boundary Evidence whose subject digest differs from its artifact", () => { + const input = exampleInput(); + const managed = input.managed_boundaries; + const result = managedNativeBoundaryInspectionSchema.parse( + managed.normalized_result, + ); + const otherDigest = "b".repeat(64); + const mismatched = createEvidence( + { + path: result.artifact.path, + sha256: result.artifact.sha256, + format: "pe", + }, + managed.provider, + { + operation: managed.operation, + parameters: managed.parameters, + result: { + ...result, + artifact: { ...result.artifact, sha256: otherDigest }, + identity_scope: { + ...result.identity_scope, + requires_artifact_sha256: otherDigest, + }, + }, + rawResult: managed.raw_result, + confidence: managed.confidence, + authority: managed.authority, + environment: managed.environment, + limitations: managed.limitations, + locations: managed.locations, + evidenceLinks: managed.evidence_links, + }, + ); + + expect(() => + verifyManagedNativeBoundaries({ + ...input, + managed_boundaries: mismatched, + }), + ).toThrow( + `Managed Evidence inspect_managed_native_boundaries (${mismatched.evidence_id}) subject SHA-256 ${result.artifact.sha256} does not match normalized artifact SHA-256 ${otherDigest}`, + ); + expect(verifyManagedNativeBoundaries(input).summary.verified).toBe(1); + }); +}); + describe("managed/native boundary verification", () => { it("verifies a P/Invoke declaration against native export Evidence", () => { const result = verifyManagedNativeBoundaries(exampleInput());