diff --git a/src/domain/conformanceTrustGate.ts b/src/domain/conformanceTrustGate.ts index 1642a721c..0b3e02b1c 100644 --- a/src/domain/conformanceTrustGate.ts +++ b/src/domain/conformanceTrustGate.ts @@ -319,11 +319,11 @@ function sortKeys(value: unknown): unknown { if (value === null || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(sortKeys); if (!isRecord(value)) return value; - const sorted: Record = {}; - for (const key of Object.keys(value).sort()) { - sorted[key] = sortKeys(value[key]); - } - return sorted; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortKeys(value[key])]), + ); } /** diff --git a/tests/conformance/providers/conformanceReplay.test.ts b/tests/conformance/providers/conformanceReplay.test.ts index 6cce9cc28..eda3b2f20 100644 --- a/tests/conformance/providers/conformanceReplay.test.ts +++ b/tests/conformance/providers/conformanceReplay.test.ts @@ -262,3 +262,53 @@ describe("conformance replay package boundary", () => { ).rejects.toThrow(/replay_plans references unknown scenario missing/u); }); }); + +describe("conformance structured JSON drift", () => { + it("detects an added own __proto__ field in captured JSON as drift", async () => { + const expected = createEvidence( + undefined, + { id: "fixture-provider", name: "Fixture provider", version: "1" }, + { operation: "run", parameters: {}, result: { filesystem: {} } }, + ); + const pkg = createConformancePackage({ + ...validPackageInput, + expected_evidence: [ + { + scenario_id: "s1", + envelopes: [expected], + bundle: null, + required_dimensions: ["filesystem"], + }, + ], + verifier_contracts: [ + { + scenario_id: "s1", + dimensions: [ + { name: "filesystem", required: true, comparison: "semantic" }, + ], + timing_tolerance_ms: 0, + }, + ], + }); + const captured: unknown = JSON.parse('{"__proto__":{"added":true}}'); + const actualEvidenceId = `ev_${"4".repeat(64)}`; + const result = await replayConformancePackage( + pkg, + { + s1: { + evidence_id: actualEvidenceId, + normalized_result: { filesystem: captured }, + }, + }, + passingRunner, + ); + + expect(result.drift_detected).toBe(true); + expect(result.trust_gate_results[0]?.verdict).toBe("fail"); + expect(result.first_drift).toMatchObject({ + scenario_id: "s1", + dimension: "filesystem", + evidence_id: actualEvidenceId, + }); + }); +}); diff --git a/tests/conformance/providers/conformanceTrustGate.test.ts b/tests/conformance/providers/conformanceTrustGate.test.ts index 5052066c8..4de72fb96 100644 --- a/tests/conformance/providers/conformanceTrustGate.test.ts +++ b/tests/conformance/providers/conformanceTrustGate.test.ts @@ -198,3 +198,39 @@ describe("conformance trust gates", () => { expect(result.verdict).toBe("fail"); }); }); + +describe("conformance structured JSON comparison", () => { + it.each([ + ['{"__proto__":{"version":1}}', '{"__proto__":{"version":2}}'], + ['{"__proto__":"before"}', '{"__proto__":"after"}'], + ["{}", '{"__proto__":null}'], + [ + '{"entries":[{"payload":{"__proto__":{"version":1}}}]}', + '{"entries":[{"payload":{"__proto__":{"version":2}}}]}', + ], + ['{"constructor":1}', '{"constructor":2}'], + ['{"prototype":1}', '{"prototype":2}'], + ])("preserves changed own JSON keys in %s", (before, after) => { + const expected: unknown = JSON.parse(before); + const actual: unknown = JSON.parse(after); + + expect(compareDimension("filesystem", expected, actual)).toMatchObject({ + status: "mismatch", + }); + expect(JSON.stringify(expected)).toBe(before); + expect(JSON.stringify(actual)).toBe(after); + }); + + it("ignores key order while preserving own JSON keys in nested objects", () => { + const expected: unknown = JSON.parse( + '{"__proto__":{"a":1,"b":2},"constructor":[{"x":1,"prototype":2}]}', + ); + const actual: unknown = JSON.parse( + '{"constructor":[{"prototype":2,"x":1}],"__proto__":{"b":2,"a":1}}', + ); + + expect(compareDimension("filesystem", expected, actual)).toMatchObject({ + status: "match", + }); + }); +});