diff --git a/.buildkite/scripts/steps/security/third_party_packages.txt b/.buildkite/scripts/steps/security/third_party_packages.txt index 5a4f138819e59..abbb782157aa7 100644 --- a/.buildkite/scripts/steps/security/third_party_packages.txt +++ b/.buildkite/scripts/steps/security/third_party_packages.txt @@ -1,3 +1,4 @@ +safe-stable-stringify ajv-draft-04 ajv-formats @moonrepo/cli diff --git a/package.json b/package.json index 15173745a0ee9..ad74c11b75947 100644 --- a/package.json +++ b/package.json @@ -1364,9 +1364,6 @@ "js-yaml": "4.1.1", "jsdom": "20.0.1", "json-schema-to-ts": "3.1.1", - "json-stable-stringify": "1.0.1", - "json-stringify-pretty-compact": "1.2.0", - "json-stringify-safe": "5.0.1", "jsonwebtoken": "9.0.2", "jsts": "1.6.2", "kea": "2.6.0", @@ -1462,6 +1459,7 @@ "rison-node": "2.1.1", "rxjs": "7.8.2", "safe-squel": "5.12.5", + "safe-stable-stringify": "2.5.0", "seedrandom": "3.0.5", "semver": "7.7.3", "snakecase-keys": "8.0.1", @@ -1863,7 +1861,6 @@ "@types/js-yaml": "4.0.9", "@types/jsdom": "20.0.1", "@types/json-schema": "7.0.15", - "@types/json-stable-stringify": "1.0.32", "@types/json5": "2.2.0", "@types/jsonwebtoken": "9.0.10", "@types/license-checker": "25.0.6", diff --git a/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts b/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts index a72910a4b3841..9b8da3fd113fc 100644 --- a/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts +++ b/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts @@ -8,13 +8,13 @@ */ import { diffStrings } from '@kbn/dev-utils'; -import jsonStable from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; export function diffCacheKey(expected?: unknown, actual?: unknown) { - const expectedJson = jsonStable(expected, { + const expectedJson = stableStringify(expected, { space: ' ', }); - const actualJson = jsonStable(actual, { + const actualJson = stableStringify(actual, { space: ' ', }); diff --git a/renovate.json b/renovate.json index fc686b3617c5c..6ad5cc7816487 100644 --- a/renovate.json +++ b/renovate.json @@ -938,6 +938,25 @@ "enabled": true, "minimumReleaseAge": "14 days" }, + { + "groupName": "safe-stable-stringify", + "matchDepNames": [ + "safe-stable-stringify" + ], + "reviewers": [ + "team:kibana-core" + ], + "matchBaseBranches": [ + "main" + ], + "labels": [ + "Team:Core", + "release_note:skip", + "backport:all-open" + ], + "enabled": true, + "minimumReleaseAge": "14 days" + }, { "groupName": "@elastic/kibana-core dependencies", "matchDepNames": [ @@ -1623,26 +1642,6 @@ "minimumReleaseAge": "14 days", "enabled": false }, - { - "groupName": "json-stable-stringify", - "matchDepNames": [ - "json-stable-stringify", - "@types/json-stable-stringify" - ], - "reviewers": [ - "team:kibana-operations" - ], - "matchBaseBranches": [ - "main" - ], - "labels": [ - "Team:Operations", - "release_note:skip", - "backport:all-open" - ], - "minimumReleaseAge": "14 days", - "enabled": true - }, { "groupName": "jsdom", "matchDepNames": [ @@ -2258,25 +2257,6 @@ "minimumReleaseAge": "14 days", "enabled": true }, - { - "groupName": "json-stringify-pretty-compact", - "matchDepNames": [ - "json-stringify-pretty-compact" - ], - "reviewers": [ - "team:kibana-visualizations" - ], - "matchBaseBranches": [ - "main" - ], - "labels": [ - "Feature:Vega", - "Team:Visualizations", - "release_note:skip" - ], - "minimumReleaseAge": "14 days", - "enabled": true - }, { "groupName": "@elastic/kibana-visualizations Color related modules", "matchDepNames": [ @@ -4580,7 +4560,6 @@ "@types/stats-lite", "@types/textarea-caret", "email-addresses", - "json-stringify-safe", "murmurhash", "stats-lite", "textarea-caret" @@ -4926,4 +4905,4 @@ "datasourceTemplate": "docker" } ] -} +} \ No newline at end of file diff --git a/src/core/packages/saved-objects/server-internal/src/routes/export.ts b/src/core/packages/saved-objects/server-internal/src/routes/export.ts index b33dbf95eddc1..b5e52e3db7333 100644 --- a/src/core/packages/saved-objects/server-internal/src/routes/export.ts +++ b/src/core/packages/saved-objects/server-internal/src/routes/export.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { schema } from '@kbn/config-schema'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils'; import type { KibanaRequest } from '@kbn/core-http-server'; @@ -264,7 +264,7 @@ NOTE: The \`savedObjects.maxImportExportSize\` configuration setting limits the const docsToExport: string[] = await createPromiseFromStreams([ exportStream, createMapStream((obj: unknown) => { - return stringify(obj); + return stableStringify(obj); }), createConcatStream([]), ]); diff --git a/src/platform/packages/shared/kbn-es-archiver/moon.yml b/src/platform/packages/shared/kbn-es-archiver/moon.yml index f8bac9bc38327..513da0bb5fdc3 100644 --- a/src/platform/packages/shared/kbn-es-archiver/moon.yml +++ b/src/platform/packages/shared/kbn-es-archiver/moon.yml @@ -28,6 +28,7 @@ dependsOn: - '@kbn/repo-info' - '@kbn/jest-serializers' - '@kbn/apm-utils' + - '@kbn/std' tags: - test-helper - package diff --git a/src/platform/packages/shared/kbn-es-archiver/src/lib/archives/format.ts b/src/platform/packages/shared/kbn-es-archiver/src/lib/archives/format.ts index 8e373e9842f8b..0813e8cb07e62 100644 --- a/src/platform/packages/shared/kbn-es-archiver/src/lib/archives/format.ts +++ b/src/platform/packages/shared/kbn-es-archiver/src/lib/archives/format.ts @@ -9,14 +9,14 @@ import { createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { PassThrough } from 'stream'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import { createMapStream, createIntersperseStream } from '@kbn/utils'; import { RECORD_SEPARATOR } from './constants'; export function createFormatArchiveStreams({ gzip = false }: { gzip?: boolean } = {}) { return [ - createMapStream((record) => stringify(record, { space: ' ' })), + createMapStream((record) => stableStringify(record, { space: ' ' })), createIntersperseStream(RECORD_SEPARATOR), gzip ? createGzip({ level: Z_BEST_COMPRESSION }) : new PassThrough(), ]; diff --git a/src/platform/packages/shared/kbn-es-archiver/tsconfig.json b/src/platform/packages/shared/kbn-es-archiver/tsconfig.json index d1c0de106fa9a..f7526b5c0db5e 100644 --- a/src/platform/packages/shared/kbn-es-archiver/tsconfig.json +++ b/src/platform/packages/shared/kbn-es-archiver/tsconfig.json @@ -22,6 +22,7 @@ "@kbn/repo-info", "@kbn/jest-serializers", "@kbn/apm-utils", + "@kbn/std", ], "exclude": [ "target/**/*", diff --git a/src/platform/packages/shared/kbn-std/index.ts b/src/platform/packages/shared/kbn-std/index.ts index 208d66a6ef3e3..6a46e63acb214 100644 --- a/src/platform/packages/shared/kbn-std/index.ts +++ b/src/platform/packages/shared/kbn-std/index.ts @@ -36,6 +36,11 @@ export { matchWildcardPattern } from './src/match_wildcard_pattern'; export { safeJsonParse } from './src/safe_json_parse'; export { safeJsonStringify } from './src/safe_json_stringify'; +export { stableStringify, type StableStringifyOptions } from './src/stable_stringify'; +export { + prettyCompactStringify, + type PrettyCompactStringifyOptions, +} from './src/pretty_compact_stringify'; export { bytePartition } from './src/byte_partition/byte_partition'; diff --git a/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.test.ts b/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.test.ts new file mode 100644 index 0000000000000..51d69675bed51 --- /dev/null +++ b/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { prettyCompactStringify } from './pretty_compact_stringify'; + +describe('prettyCompactStringify', () => { + it('stringifies primitives correctly', () => { + expect(prettyCompactStringify(null)).toBe('null'); + expect(prettyCompactStringify(undefined)).toBe('null'); + expect(prettyCompactStringify(true)).toBe('true'); + expect(prettyCompactStringify(false)).toBe('false'); + expect(prettyCompactStringify(42)).toBe('42'); + expect(prettyCompactStringify('hello')).toBe('"hello"'); + }); + + it('keeps short arrays on a single line', () => { + expect(prettyCompactStringify([1, 2, 3])).toBe('[1, 2, 3]'); + expect(prettyCompactStringify(['a', 'b'])).toBe('["a", "b"]'); + }); + + it('keeps short objects on a single line', () => { + expect(prettyCompactStringify({ a: 1, b: 2 })).toBe('{"a": 1, "b": 2}'); + }); + + it('expands long arrays to multiple lines', () => { + const longArray = [ + { x: 1, y: 2, description: 'point one' }, + { x: 2, y: 1, description: 'point two' }, + ]; + const result = prettyCompactStringify(longArray, { maxLength: 40 }); + expect(result).toContain('\n'); + }); + + it('expands long objects to multiple lines', () => { + const longObject = { + name: 'test', + description: 'a very long description that exceeds the maximum line length', + }; + const result = prettyCompactStringify(longObject, { maxLength: 40 }); + expect(result).toContain('\n'); + }); + + it('returns empty array notation for empty arrays', () => { + expect(prettyCompactStringify([])).toBe('[]'); + }); + + it('returns empty object notation for empty objects', () => { + expect(prettyCompactStringify({})).toBe('{}'); + }); + + it('respects maxLength option', () => { + const obj = { a: 1, b: 2, c: 3 }; + // With a very small maxLength, should expand + const result = prettyCompactStringify(obj, { maxLength: 10 }); + expect(result).toContain('\n'); + }); + + it('respects indent option', () => { + const obj = { nested: { value: 1 } }; + const result = prettyCompactStringify(obj, { maxLength: 10, indent: 4 }); + // Should have 4-space indentation + expect(result).toContain(' '); + }); + + it('supports replacer function', () => { + const obj = { a: 1, b: 'secret' }; + const replacer = (key: string, value: unknown) => { + if (key === 'b') return undefined; + return value; + }; + expect(prettyCompactStringify(obj, { replacer })).toBe('{"a": 1}'); + }); + + it('supports replacer array', () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(prettyCompactStringify(obj, { replacer: ['a', 'c'] })).toBe('{"a": 1, "c": 3}'); + }); + + it('handles nested structures correctly', () => { + const obj = { + items: [1, 2], + meta: { count: 2 }, + }; + const result = prettyCompactStringify(obj); + // Short enough to fit on one line with default maxLength + expect(result).toBe('{"items": [1, 2], "meta": {"count": 2}}'); + }); + + it('defaults maxLength to 80', () => { + // Create an object that's under 80 chars but would exceed if maxLength were smaller + const obj = { short: 'value', another: 'property' }; + const result = prettyCompactStringify(obj); + // Should stay on one line since it's under 80 chars + expect(result).not.toContain('\n'); + }); +}); diff --git a/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.ts b/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.ts new file mode 100644 index 0000000000000..4490c264fbbf0 --- /dev/null +++ b/src/platform/packages/shared/kbn-std/src/pretty_compact_stringify.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export interface PrettyCompactStringifyOptions { + /** + * Maximum line length before breaking to multiple lines. + * @default 80 + */ + maxLength?: number; + /** + * Number of spaces for indentation. + * @default 2 + */ + indent?: number; + /** + * A function that alters the behavior of the stringification process, + * or an array of strings/numbers to filter properties. + */ + replacer?: ((key: string, value: unknown) => unknown) | (string | number)[] | null; +} + +/** + * Pretty-prints JSON in a compact format that balances readability with space efficiency. + * + * Short objects and arrays are kept on a single line when they fit within `maxLength`. + * Longer structures are broken across multiple lines with proper indentation. + * + * This is useful for displaying JSON in editors or logs where both readability + * and compact output are desired. + * + * @param value - The value to stringify + * @param options - Configuration options for formatting + * @returns A formatted JSON string + * + * @example + * // Short arrays stay on one line + * prettyCompactStringify([1, 2, 3]) + * // => "[1, 2, 3]" + * + * @example + * // Long arrays break to multiple lines + * prettyCompactStringify([{ x: 1, y: 2 }, { x: 2, y: 1 }], { maxLength: 20 }) + * // => "[\n {\"x\": 1, \"y\": 2},\n {\"x\": 2, \"y\": 1}\n]" + */ +export function prettyCompactStringify( + value: unknown, + options: PrettyCompactStringifyOptions = {} +): string { + const { maxLength = 80, indent = 2, replacer = null } = options; + + return stringifyValue(value, '', indent, maxLength, replacer); +} + +function stringifyValue( + value: unknown, + currentIndent: string, + indentStep: number, + maxLength: number, + replacer: ((key: string, value: unknown) => unknown) | (string | number)[] | null +): string { + // Handle primitives + if (value === null || value === undefined) { + return 'null'; + } + + const type = typeof value; + if (type === 'boolean' || type === 'number') { + return String(value); + } + if (type === 'string') { + return JSON.stringify(value); + } + + // Handle arrays + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]'; + } + + // Try compact form first + const compactItems = value.map((item) => + stringifyValue(item, '', indentStep, maxLength, replacer) + ); + const compactResult = '[' + compactItems.join(', ') + ']'; + + if (compactResult.length <= maxLength && !compactResult.includes('\n')) { + return compactResult; + } + + // Fall back to expanded form + const nextIndent = currentIndent + ' '.repeat(indentStep); + const expandedItems = value.map( + (item) => nextIndent + stringifyValue(item, nextIndent, indentStep, maxLength, replacer) + ); + return '[\n' + expandedItems.join(',\n') + '\n' + currentIndent + ']'; + } + + // Handle objects + if (type === 'object') { + const obj = value as Record; + let keys = Object.keys(obj); + + // Apply replacer if it's an array + if (Array.isArray(replacer)) { + const allowedKeys = new Set(replacer.map(String)); + keys = keys.filter((k) => allowedKeys.has(k)); + } + + if (keys.length === 0) { + return '{}'; + } + + // Build key-value pairs + const pairs = keys + .map((key) => { + let val = obj[key]; + + // Apply replacer if it's a function + if (typeof replacer === 'function') { + val = replacer(key, val); + } + + if (val === undefined) { + return null; + } + + const stringifiedValue = stringifyValue(val, '', indentStep, maxLength, replacer); + return { key, value: stringifiedValue }; + }) + .filter((pair): pair is { key: string; value: string } => pair !== null); + + if (pairs.length === 0) { + return '{}'; + } + + // Try compact form first + const compactPairs = pairs.map((p) => JSON.stringify(p.key) + ': ' + p.value); + const compactResult = '{' + compactPairs.join(', ') + '}'; + + if (compactResult.length <= maxLength && !compactResult.includes('\n')) { + return compactResult; + } + + // Fall back to expanded form + const nextIndent = currentIndent + ' '.repeat(indentStep); + const expandedPairs = pairs.map((p) => { + const expandedValue = stringifyValue(obj[p.key], nextIndent, indentStep, maxLength, replacer); + return nextIndent + JSON.stringify(p.key) + ': ' + expandedValue; + }); + return '{\n' + expandedPairs.join(',\n') + '\n' + currentIndent + '}'; + } + + // Fallback for any other type + return JSON.stringify(value); +} diff --git a/src/platform/packages/shared/kbn-std/src/safe_json_stringify.test.ts b/src/platform/packages/shared/kbn-std/src/safe_json_stringify.test.ts new file mode 100644 index 0000000000000..c5b4891ddeee1 --- /dev/null +++ b/src/platform/packages/shared/kbn-std/src/safe_json_stringify.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { safeJsonStringify } from './safe_json_stringify'; + +describe('safeJsonStringify', () => { + it('stringifies primitives correctly', () => { + expect(safeJsonStringify(null)).toBe('null'); + expect(safeJsonStringify(true)).toBe('true'); + expect(safeJsonStringify(false)).toBe('false'); + expect(safeJsonStringify(42)).toBe('42'); + expect(safeJsonStringify('hello')).toBe('"hello"'); + }); + + it('stringifies arrays correctly', () => { + expect(safeJsonStringify([1, 2, 3])).toBe('[1,2,3]'); + expect(safeJsonStringify(['a', 'b'])).toBe('["a","b"]'); + }); + + it('stringifies objects correctly', () => { + expect(safeJsonStringify({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + }); + + it('preserves original key order (not sorted)', () => { + const obj = { z: 1, a: 2, m: 3 }; + // Unlike stableStringify, safeJsonStringify preserves insertion order + expect(safeJsonStringify(obj)).toBe('{"z":1,"a":2,"m":3}'); + }); + + it('omits circular references from output', () => { + const obj: Record = { a: 1 }; + obj.self = obj; + const result = safeJsonStringify(obj); + // Circular reference is omitted (treated as undefined) + expect(result).toBe('{"a":1}'); + }); + + it('omits deeply nested circular references from output', () => { + const obj: { a: { b: { c: unknown } } } = { + a: { + b: { + c: {}, + }, + }, + }; + obj.a.b.c = obj; + const result = safeJsonStringify(obj); + // Circular reference in nested object is omitted + expect(result).toBe('{"a":{"b":{}}}'); + }); + + it('omits multiple circular references from output', () => { + const obj: Record = { a: 1 }; + obj.ref1 = obj; + obj.ref2 = obj; + const result = safeJsonStringify(obj); + // All circular references are omitted + expect(result).toBe('{"a":1}'); + }); + + it('does not call handleError when circular references are handled', () => { + const obj: Record = { a: 1 }; + obj.self = obj; + const handleError = jest.fn(() => 'error occurred'); + const result = safeJsonStringify(obj, handleError); + // Circular references are handled gracefully, no error + expect(result).toBe('{"a":1}'); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('returns undefined for unsupported types', () => { + // Functions cannot be stringified + expect(safeJsonStringify(() => {})).toBe(undefined); + }); +}); diff --git a/src/platform/packages/shared/kbn-std/src/safe_json_stringify.ts b/src/platform/packages/shared/kbn-std/src/safe_json_stringify.ts index 3bb8b2565d842..1bb0b98c297f8 100644 --- a/src/platform/packages/shared/kbn-std/src/safe_json_stringify.ts +++ b/src/platform/packages/shared/kbn-std/src/safe_json_stringify.ts @@ -7,16 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { configure } from 'safe-stable-stringify'; + +// Configure stringify to: +// - NOT sort keys (deterministic: false) to match JSON.stringify behavior +// - Omit circular references by setting their value to undefined +const stringify = configure({ deterministic: false, circularValue: undefined }); + const noop = (): string | undefined => { return undefined; }; /** - * Safely stringifies a value to JSON. If the value cannot be stringified, - * for instance if it contains circular references, it will return `undefined`. - * If `handleError` is defined, it will be called with the error, and the - * response will be returned. This allows consumers to wrap the JSON.stringify - * error. + * Safely stringifies a value to JSON. Handles circular references by omitting + * them from the output (treating them as `undefined`). + * + * If an unexpected error occurs during stringification, `handleError` will be + * called if provided, otherwise `undefined` is returned. * * @param value The value to stringify. * @param handleError Optional callback that is called when an error occurs during @@ -29,7 +36,7 @@ export function safeJsonStringify( handleError: (error: Error) => string | undefined = noop ): string | undefined { try { - return JSON.stringify(value); + return stringify(value); } catch (error) { return handleError(error); } diff --git a/src/platform/packages/shared/kbn-std/src/stable_stringify.test.ts b/src/platform/packages/shared/kbn-std/src/stable_stringify.test.ts new file mode 100644 index 0000000000000..392b8d3ca652b --- /dev/null +++ b/src/platform/packages/shared/kbn-std/src/stable_stringify.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { stableStringify } from './stable_stringify'; + +describe('stableStringify', () => { + it('stringifies primitives correctly', () => { + expect(stableStringify(null)).toBe('null'); + expect(stableStringify(true)).toBe('true'); + expect(stableStringify(false)).toBe('false'); + expect(stableStringify(42)).toBe('42'); + expect(stableStringify('hello')).toBe('"hello"'); + }); + + it('stringifies arrays correctly', () => { + expect(stableStringify([1, 2, 3])).toBe('[1,2,3]'); + expect(stableStringify(['a', 'b'])).toBe('["a","b"]'); + }); + + it('sorts object keys alphabetically', () => { + const obj = { z: 1, a: 2, m: 3 }; + expect(stableStringify(obj)).toBe('{"a":2,"m":3,"z":1}'); + }); + + it('produces consistent output regardless of property insertion order', () => { + const obj1 = { b: 2, a: 1 }; + const obj2 = { a: 1, b: 2 }; + expect(stableStringify(obj1)).toBe(stableStringify(obj2)); + }); + + it('handles nested objects with sorted keys', () => { + const obj = { + z: { c: 1, a: 2 }, + a: { z: 3, b: 4 }, + }; + expect(stableStringify(obj)).toBe('{"a":{"b":4,"z":3},"z":{"a":2,"c":1}}'); + }); + + it('handles circular references by replacing with [Circular]', () => { + const obj: Record = { a: 1 }; + obj.self = obj; + expect(stableStringify(obj)).toBe('{"a":1,"self":"[Circular]"}'); + }); + + it('supports the space option for pretty-printing', () => { + const obj = { b: 2, a: 1 }; + const result = stableStringify(obj, { space: 2 }); + expect(result).toContain('\n'); + expect(result).toContain(' '); + // Keys should still be sorted + expect(result.indexOf('"a"')).toBeLessThan(result.indexOf('"b"')); + }); + + it('supports the space option as a string', () => { + const obj = { a: 1 }; + const result = stableStringify(obj, { space: '\t' }); + expect(result).toContain('\t'); + }); + + it('supports the replacer function option', () => { + const obj = { a: 1, b: 'secret' }; + const replacer = (key: string, value: unknown) => { + if (key === 'b') return '[REDACTED]'; + return value; + }; + expect(stableStringify(obj, { replacer })).toBe('{"a":1,"b":"[REDACTED]"}'); + }); + + it('supports the replacer array option', () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(stableStringify(obj, { replacer: ['a', 'c'] })).toBe('{"a":1,"c":3}'); + }); + + it('returns empty string for undefined', () => { + expect(stableStringify(undefined)).toBe(''); + }); +}); diff --git a/src/platform/packages/shared/kbn-std/src/stable_stringify.ts b/src/platform/packages/shared/kbn-std/src/stable_stringify.ts new file mode 100644 index 0000000000000..45914a486c64b --- /dev/null +++ b/src/platform/packages/shared/kbn-std/src/stable_stringify.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { stringify } from 'safe-stable-stringify'; + +export interface StableStringifyOptions { + /** + * Adds indentation for pretty-printing. Can be a number of spaces or a string. + */ + space?: string | number; + /** + * A function or array to filter/transform values during stringification. + */ + replacer?: ((key: string, value: unknown) => unknown) | (string | number)[] | null; +} + +/** + * Deterministically stringifies a value to JSON with sorted keys. + * This ensures consistent output regardless of property insertion order, + * making it ideal for: + * - Generating cache keys + * - Creating hashes for comparison + * - Cryptographic operations requiring deterministic input + * + * Also handles circular references safely by replacing them with "[Circular]". + * + * @param value - The value to stringify + * @param options - Optional configuration for formatting + * @returns A JSON string with keys sorted alphabetically + */ +export function stableStringify(value: unknown, options?: StableStringifyOptions): string { + const { space, replacer } = options ?? {}; + return stringify(value, replacer as Parameters[1], space) ?? ''; +} diff --git a/src/platform/packages/shared/kbn-storage-adapter/moon.yml b/src/platform/packages/shared/kbn-storage-adapter/moon.yml index 91367e6e1bb2a..8760e8f0579ee 100644 --- a/src/platform/packages/shared/kbn-storage-adapter/moon.yml +++ b/src/platform/packages/shared/kbn-storage-adapter/moon.yml @@ -22,6 +22,7 @@ dependsOn: - '@kbn/es-types' - '@kbn/es-errors' - '@kbn/core-test-helpers-kbn-server' + - '@kbn/std' tags: - shared-server - package diff --git a/src/platform/packages/shared/kbn-storage-adapter/src/get_schema_version.ts b/src/platform/packages/shared/kbn-storage-adapter/src/get_schema_version.ts index 4560fdca84565..21731382bc2ce 100644 --- a/src/platform/packages/shared/kbn-storage-adapter/src/get_schema_version.ts +++ b/src/platform/packages/shared/kbn-storage-adapter/src/get_schema_version.ts @@ -7,11 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import objectHash from 'object-hash'; import type { IndexStorageSettings } from '..'; export function getSchemaVersion(storage: IndexStorageSettings): string { - const version = objectHash(stringify(storage.schema.properties)); + const version = objectHash(stableStringify(storage.schema.properties)); return version; } diff --git a/src/platform/packages/shared/kbn-storage-adapter/tsconfig.json b/src/platform/packages/shared/kbn-storage-adapter/tsconfig.json index 54b93f0f570cc..bb64244485bcf 100644 --- a/src/platform/packages/shared/kbn-storage-adapter/tsconfig.json +++ b/src/platform/packages/shared/kbn-storage-adapter/tsconfig.json @@ -20,5 +20,6 @@ "@kbn/es-types", "@kbn/es-errors", "@kbn/core-test-helpers-kbn-server", + "@kbn/std", ] } diff --git a/src/platform/plugins/private/vis_types/vega/public/components/vega_vis_editor.tsx b/src/platform/plugins/private/vis_types/vega/public/components/vega_vis_editor.tsx index b74a619538849..ffc8b896ef769 100644 --- a/src/platform/plugins/private/vis_types/vega/public/components/vega_vis_editor.tsx +++ b/src/platform/plugins/private/vis_types/vega/public/components/vega_vis_editor.tsx @@ -12,7 +12,7 @@ import useMount from 'react-use/lib/useMount'; import hjson from 'hjson'; import React, { useCallback, useState } from 'react'; -import compactStringify from 'json-stringify-pretty-compact'; +import { prettyCompactStringify } from '@kbn/std'; import { i18n } from '@kbn/i18n'; import type { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; @@ -27,7 +27,7 @@ import { VegaActionsMenu } from './vega_actions_menu'; function format( value: string, - stringify: typeof hjson.stringify | typeof compactStringify, + stringify: typeof hjson.stringify | typeof prettyCompactStringify, options?: any ) { try { @@ -111,7 +111,7 @@ function VegaVisEditor({ stateParams, setValue }: VisEditorOptionsProps setSpec(value), [setSpec]); const formatJson = useCallback(() => { - const { value, isValid } = format(stateParams.spec, compactStringify); + const { value, isValid } = format(stateParams.spec, prettyCompactStringify); if (isValid) { setSpec(value, XJsonLang.ID); diff --git a/src/platform/plugins/private/vis_types/vega/public/data_model/utils.ts b/src/platform/plugins/private/vis_types/vega/public/data_model/utils.ts index c138f5a966e54..5863f542bd196 100644 --- a/src/platform/plugins/private/vis_types/vega/public/data_model/utils.ts +++ b/src/platform/plugins/private/vis_types/vega/public/data_model/utils.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import compactStringify from 'json-stringify-pretty-compact'; +import { prettyCompactStringify } from '@kbn/std'; import type { CoreTheme } from '@kbn/core/public'; import { getEuiThemeVars } from '@kbn/ui-theme'; import { normalizeObject } from '../vega_view/utils'; @@ -16,7 +16,7 @@ function normalizeAndStringify(value: unknown) { if (typeof value === 'string') { return value; } - return compactStringify(normalizeObject(value), { maxLength: 70 }); + return prettyCompactStringify(normalizeObject(value), { maxLength: 70 }); } export class Utils { diff --git a/src/platform/plugins/shared/data/moon.yml b/src/platform/plugins/shared/data/moon.yml index 46894686a1ea8..88d3d4914e241 100644 --- a/src/platform/plugins/shared/data/moon.yml +++ b/src/platform/plugins/shared/data/moon.yml @@ -68,6 +68,7 @@ dependsOn: - '@kbn/core-application-browser-mocks' - '@kbn/cps' - '@kbn/cps-utils' + - '@kbn/std' tags: - plugin - prod diff --git a/src/platform/plugins/shared/data/public/search/search_interceptor/create_request_hash.ts b/src/platform/plugins/shared/data/public/search/search_interceptor/create_request_hash.ts index 71dd029817b52..6f4737d4eda2a 100644 --- a/src/platform/plugins/shared/data/public/search/search_interceptor/create_request_hash.ts +++ b/src/platform/plugins/shared/data/public/search/search_interceptor/create_request_hash.ts @@ -8,7 +8,7 @@ */ import { Sha256 } from '@kbn/crypto-browser'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; /** * Generate the hash for this request. @@ -21,7 +21,7 @@ import stringify from 'json-stable-stringify'; */ function createRequestHash(keys: Record) { const { preference, ...rest } = keys; - const hash = new Sha256().update(stringify(rest), 'utf8').digest('hex'); + const hash = new Sha256().update(stableStringify(rest), 'utf8').digest('hex'); return hash; } diff --git a/src/platform/plugins/shared/data/tsconfig.json b/src/platform/plugins/shared/data/tsconfig.json index c56f40c3cb58f..fe78418301688 100644 --- a/src/platform/plugins/shared/data/tsconfig.json +++ b/src/platform/plugins/shared/data/tsconfig.json @@ -63,6 +63,7 @@ "@kbn/core-application-browser-mocks", "@kbn/cps", "@kbn/cps-utils", + "@kbn/std", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/vis_types/timeseries/server/create_request_hash.ts b/src/platform/plugins/shared/vis_types/timeseries/server/create_request_hash.ts index bab2c2ece843b..2576e21c05c71 100644 --- a/src/platform/plugins/shared/vis_types/timeseries/server/create_request_hash.ts +++ b/src/platform/plugins/shared/vis_types/timeseries/server/create_request_hash.ts @@ -8,7 +8,7 @@ */ import { createHash } from 'crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; /** * Generate the hash for this request so that, in the future, this hash can be used to look up @@ -17,5 +17,5 @@ import stringify from 'json-stable-stringify'; */ export function createRequestHash(keys: Record) { const { preference, ...params } = keys; - return createHash(`sha256`).update(stringify(params)).digest('hex'); + return createHash(`sha256`).update(stableStringify(params)).digest('hex'); } diff --git a/x-pack/platform/plugins/private/reindex_service/server/src/lib/credential_store.ts b/x-pack/platform/plugins/private/reindex_service/server/src/lib/credential_store.ts index b540e25badb52..0176ca8bbe2b2 100644 --- a/x-pack/platform/plugins/private/reindex_service/server/src/lib/credential_store.ts +++ b/x-pack/platform/plugins/private/reindex_service/server/src/lib/credential_store.ts @@ -6,7 +6,7 @@ */ import { createHash } from 'crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import type { KibanaRequest, Logger } from '@kbn/core/server'; @@ -22,7 +22,7 @@ const getHash = (reindexOp: ReindexSavedObject) => { // This needs further investigation, see: https://github.com/elastic/kibana/issues/123752 const { reindexOptions, ...attributes } = reindexOp.attributes; return createHash('sha256') - .update(stringify({ id: reindexOp.id, ...attributes })) + .update(stableStringify({ id: reindexOp.id, ...attributes })) .digest('base64'); }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts index 984933bb7cc24..deef48665f3a3 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts @@ -7,7 +7,7 @@ import qs from 'query-string'; import axios from 'axios'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import type { Logger } from '@kbn/core/server'; import { request } from './axios_utils'; import type { ActionsConfigurationUtilities } from '../actions_config'; @@ -51,7 +51,7 @@ export async function requestOAuthToken( expiresIn: res.data.expires_in, }; } else { - const errString = stringify(res.data); + const errString = stableStringify(res.data); logger.warn(`error thrown getting the access token from ${tokenUrl}: ${errString}`); throw new Error(errString); } diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts index 3c1d5390d4d2b..4e25f048f14ed 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts @@ -5,14 +5,13 @@ * 2.0. */ -import stringify from 'json-stable-stringify'; import pMap from 'p-map'; import { get, partition, pick } from 'lodash'; import dateMath from '@kbn/datemath'; import { CaseStatuses } from '@kbn/cases-components'; import type { SavedObjectError } from '@kbn/core-saved-objects-common'; import type { Logger } from '@kbn/core/server'; -import { getFlattenedObject } from '@kbn/std'; +import { getFlattenedObject, stableStringify } from '@kbn/std'; import type { CustomFieldsConfiguration, TemplatesConfiguration, @@ -246,7 +245,7 @@ export class CasesConnectorExecutor { for (const alert of alertsWithAllGroupingFields) { const alertWithOnlyTheGroupingFields = pick(alert, uniqueGroupingByFields); - const groupingKey = stringify(alertWithOnlyTheGroupingFields); + const groupingKey = stableStringify(alertWithOnlyTheGroupingFields); if (this.logger.isLevelEnabled('debug')) { this.logger.debug( @@ -265,7 +264,7 @@ export class CasesConnectorExecutor { if (noGroupedAlerts.length > 0) { const noGroupedGrouping = this.generateNoGroupAlertGrouping(params.groupingBy); - groupingMap.set(stringify(noGroupedGrouping), { + groupingMap.set(stableStringify(noGroupedGrouping), { alerts: noGroupedAlerts, grouping: noGroupedGrouping, }); diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_oracle_service.test.ts b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_oracle_service.test.ts index 4d5d167a58852..6692d3d50ef0d 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_oracle_service.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_oracle_service.test.ts @@ -6,7 +6,7 @@ */ import { createHash } from 'node:crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; @@ -33,7 +33,7 @@ describe('CasesOracleService', () => { const owner = 'cases'; const grouping = { 'host.ip': '0.0.0.1' }; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}`; const hash = createHash('sha256'); hash.update(payload); @@ -50,7 +50,7 @@ describe('CasesOracleService', () => { const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' }; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(sortedGrouping)}`; const hash = createHash('sha256'); hash.update(payload); @@ -81,7 +81,7 @@ describe('CasesOracleService', () => { const owner = 'cases'; const grouping = {}; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}`; const hash = createHash('sha256'); hash.update(payload); @@ -96,7 +96,7 @@ describe('CasesOracleService', () => { const owner = 'cases'; const grouping = { 'host.ip': '0.0.0.1' }; - const payload = `${spaceId}:${owner}:${stringify(grouping)}`; + const payload = `${spaceId}:${owner}:${stableStringify(grouping)}`; const hash = createHash('sha256'); hash.update(payload); @@ -133,7 +133,7 @@ describe('CasesOracleService', () => { const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue( params.spaceId - )}${getPayloadValue(params.owner)}${stringify(grouping)}`; + )}${getPayloadValue(params.owner)}${stableStringify(grouping)}`; const hash = createHash('sha256'); @@ -151,7 +151,7 @@ describe('CasesOracleService', () => { const owner = 'cases{'; const grouping = { '{:}': `{}=:&".'/{}}` }; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}`; const hash = createHash('sha256'); hash.update(payload); diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_service.test.ts b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_service.test.ts index 183d628d7a742..67bb38d6f6416 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_service.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_service.test.ts @@ -6,7 +6,7 @@ */ import { createHash } from 'node:crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import { isEmpty } from 'lodash'; import { set } from '@kbn/safer-lodash-set'; @@ -28,7 +28,7 @@ describe('CasesService', () => { const grouping = { 'host.ip': '0.0.0.1' }; const counter = 1; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}:${counter}`; const hash = createHash('sha256'); hash.update(payload); @@ -46,7 +46,7 @@ describe('CasesService', () => { const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' }; const counter = 1; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}:${counter}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(sortedGrouping)}:${counter}`; const hash = createHash('sha256'); hash.update(payload); @@ -79,7 +79,7 @@ describe('CasesService', () => { const grouping = {}; const counter = 1; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}:${counter}`; const hash = createHash('sha256'); hash.update(payload); @@ -95,7 +95,7 @@ describe('CasesService', () => { const grouping = { 'host.ip': '0.0.0.1' }; const counter = 1; - const payload = `${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const payload = `${spaceId}:${owner}:${stableStringify(grouping)}:${counter}`; const hash = createHash('sha256'); hash.update(payload); @@ -134,7 +134,7 @@ describe('CasesService', () => { const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue( params.spaceId - )}${getPayloadValue(params.owner)}${stringify(grouping)}:${counter}`; + )}${getPayloadValue(params.owner)}${stableStringify(grouping)}:${counter}`; const hash = createHash('sha256'); @@ -153,7 +153,7 @@ describe('CasesService', () => { const grouping = { '{:}': `{}=:&".'/{}}` }; const counter = 1; - const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const payload = `${ruleId}:${spaceId}:${owner}:${stableStringify(grouping)}:${counter}`; const hash = createHash('sha256'); hash.update(payload); diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/cases/crypto_service.ts b/x-pack/platform/plugins/shared/cases/server/connectors/cases/crypto_service.ts index e35b4e51ed1b4..8455133d2c401 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/cases/crypto_service.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/cases/crypto_service.ts @@ -6,7 +6,7 @@ */ import { createHash } from 'node:crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; export class CryptoService { public getHash(payload: string): string { @@ -21,6 +21,6 @@ export class CryptoService { return null; } - return stringify(obj); + return stableStringify(obj); } } diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/moon.yml b/x-pack/platform/plugins/shared/encrypted_saved_objects/moon.yml index d8fe3492652e2..62aed109a2b0c 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/moon.yml +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/moon.yml @@ -33,6 +33,7 @@ dependsOn: - '@kbn/core-root-server-internal' - '@kbn/core-test-helpers-kbn-server' - '@kbn/core-test-helpers-so-type-serializer' + - '@kbn/std' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 61d3ad39d3a63..a754ce4ec455d 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -6,11 +6,11 @@ */ import type { Crypto, EncryptOutput } from '@elastic/node-crypto'; -import stringify from 'json-stable-stringify'; import typeDetect from 'type-detect'; import type { Logger } from '@kbn/core/server'; import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { stableStringify } from '@kbn/std'; import { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition'; import { EncryptionError, EncryptionErrorOperation } from './encryption_error'; @@ -648,7 +648,7 @@ export class EncryptedSavedObjectsService { } // Always add the descriptor to the AAD. - return stringify([...descriptorToArray(descriptor), attributesAAD]); + return stableStringify([...descriptorToArray(descriptor), attributesAAD]); } /** diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/tsconfig.json b/x-pack/platform/plugins/shared/encrypted_saved_objects/tsconfig.json index 3c0115bff7417..9b448e11e3c1a 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/tsconfig.json +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/tsconfig.json @@ -23,6 +23,7 @@ "@kbn/core-root-server-internal", "@kbn/core-test-helpers-kbn-server", "@kbn/core-test-helpers-so-type-serializer", + "@kbn/std", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/shared/licensing/server/license_fetcher.ts b/x-pack/platform/plugins/shared/licensing/server/license_fetcher.ts index 30fc77649411b..5ae2bbd49c6a1 100644 --- a/x-pack/platform/plugins/shared/licensing/server/license_fetcher.ts +++ b/x-pack/platform/plugins/shared/licensing/server/license_fetcher.ts @@ -8,9 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { createHash } from 'crypto'; import pRetry from 'p-retry'; -import stringify from 'json-stable-stringify'; import type { MaybePromise } from '@kbn/utility-types'; -import { isPromise } from '@kbn/std'; +import { isPromise, stableStringify } from '@kbn/std'; import type { IClusterClient, Logger } from '@kbn/core/server'; import type { ILicense, @@ -128,7 +127,7 @@ function sign({ }) { return createHash('sha256') .update( - stringify({ + stableStringify({ license, features, error, diff --git a/x-pack/platform/plugins/shared/logs_shared/public/components/logging/log_text_stream/field_value.tsx b/x-pack/platform/plugins/shared/logs_shared/public/components/logging/log_text_stream/field_value.tsx index b28199b9b40c9..0610a77be6c51 100644 --- a/x-pack/platform/plugins/shared/logs_shared/public/components/logging/log_text_stream/field_value.tsx +++ b/x-pack/platform/plugins/shared/logs_shared/public/components/logging/log_text_stream/field_value.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import React from 'react'; import type { JsonArray, JsonValue } from '@kbn/utility-types'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; @@ -58,7 +58,7 @@ const formatValue = (value: JsonValue): string => { return value; } - return stringify(value); + return stableStringify(value); }; const CommaSeparatedLi = euiStyled.li` diff --git a/x-pack/platform/plugins/shared/logs_shared/server/utils/typed_search_strategy.ts b/x-pack/platform/plugins/shared/logs_shared/server/utils/typed_search_strategy.ts index da49d40357bde..3231f3ad3da2e 100644 --- a/x-pack/platform/plugins/shared/logs_shared/server/utils/typed_search_strategy.ts +++ b/x-pack/platform/plugins/shared/logs_shared/server/utils/typed_search_strategy.ts @@ -6,7 +6,7 @@ */ import * as rt from 'io-ts'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import type { JsonValue } from '@kbn/utility-types'; import { jsonValueRT } from '../../common/typed_json'; import type { SearchStrategyError } from '../../common/search_strategies/common/errors'; @@ -22,7 +22,7 @@ export const jsonFromBase64StringRT = new rt.Type( return rt.failure(error, context); } }, - (a) => Buffer.from(stringify(a)).toString('base64') + (a) => Buffer.from(stableStringify(a)).toString('base64') ); export const createAsyncRequestRTs = ( diff --git a/x-pack/platform/plugins/shared/stack_connectors/moon.yml b/x-pack/platform/plugins/shared/stack_connectors/moon.yml index 23c396145ba6d..bc3e8836dd6ee 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/moon.yml +++ b/x-pack/platform/plugins/shared/stack_connectors/moon.yml @@ -62,6 +62,7 @@ dependsOn: - '@kbn/response-ops-form-generator' - '@kbn/mcp-client' - '@kbn/actions-types' + - '@kbn/std' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts index 68dad0657628f..62ac7543c04bf 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts @@ -5,11 +5,10 @@ * 2.0. */ -// @ts-expect-error missing type def -import stringify from 'json-stringify-safe'; import type { AxiosInstance, AxiosResponse } from 'axios'; import axios from 'axios'; import type { Logger } from '@kbn/core/server'; +import { safeJsonStringify } from '@kbn/std'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import type { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; @@ -91,7 +90,7 @@ async function sendEmail({ if (res.status === 202) { return res.data; } - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown sending Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` ); @@ -215,7 +214,7 @@ async function createDraft({ }); if (res.status !== 201) { - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown creating Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}` ); @@ -253,7 +252,7 @@ async function sendDraft( if (res.status === 202) { return res.data; } - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown sending Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}` ); @@ -295,7 +294,7 @@ async function createUploadSession( connectorUsageCollector, }); if (res.status !== 201) { - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown creating Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}` ); @@ -328,7 +327,7 @@ async function closeUploadSession( if (res.status === 204) { return res.data; } - const errString = stringify(`${res.status} ${res.statusText}`); + const errString = safeJsonStringify(`${res.status} ${res.statusText}`); logger.warn( `error thrown closing Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}` ); @@ -378,7 +377,7 @@ async function addAttachment( if (res.status === 201) { return res.data; } - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown adding attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` ); @@ -420,7 +419,7 @@ async function uploadAttachmentChunk( }); if (res.status !== 200 && res.status !== 201) { - const errString = stringify(res.data); + const errString = safeJsonStringify(res.data); logger.warn( `error thrown uploading attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` ); diff --git a/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json b/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json index 2512adf2d3ef8..129cbe5ba6527 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json +++ b/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json @@ -56,6 +56,7 @@ "@kbn/response-ops-form-generator", "@kbn/mcp-client", "@kbn/actions-types", + "@kbn/std", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts index f363685c93d55..570799d7ef257 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import type SuperTest from 'supertest'; import { createHash } from 'node:crypto'; -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import type { CasesConnectorRunParams, OracleRecordAttributes, @@ -1862,7 +1862,9 @@ const generateId = ({ spaceId?: string; owner?: string; }) => { - const payload = [ruleId, spaceId, owner, stringify(grouping), counter].filter(Boolean).join(':'); + const payload = [ruleId, spaceId, owner, stableStringify(grouping), counter] + .filter(Boolean) + .join(':'); const hash = createHash('sha256'); hash.update(payload); diff --git a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/moon.yml b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/moon.yml index 03b02e65eaff1..7bacfea2d3bcb 100644 --- a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/moon.yml +++ b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/moon.yml @@ -30,6 +30,7 @@ dependsOn: - '@kbn/traced-es-client' - '@kbn/es-query' - '@kbn/ai-tools' + - '@kbn/std' tags: - shared-server - package diff --git a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/root_cause_analysis/tasks/find_related_entities/extract_related_entities.ts b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/root_cause_analysis/tasks/find_related_entities/extract_related_entities.ts index 7787b7cc02992..ae36793899aed 100644 --- a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/root_cause_analysis/tasks/find_related_entities/extract_related_entities.ts +++ b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/root_cause_analysis/tasks/find_related_entities/extract_related_entities.ts @@ -5,7 +5,7 @@ * 2.0. */ -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import pLimit from 'p-limit'; import type { RelatedEntityFromSearchResults } from '.'; import { @@ -136,7 +136,9 @@ export async function extractRelatedEntities({ }) ); - const foundEntityIds = foundEntities.map(({ entity: foundEntity }) => stringify(foundEntity)); + const foundEntityIds = foundEntities.map(({ entity: foundEntity }) => + stableStringify(foundEntity) + ); const relatedEntities = allEvents .flat() @@ -150,7 +152,7 @@ export async function extractRelatedEntities({ }); }) .filter((item) => { - return foundEntityIds.includes(stringify(item.entity)); + return foundEntityIds.includes(stableStringify(item.entity)); }); return { diff --git a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/tsconfig.json b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/tsconfig.json index 4d85be3003ad7..dd7656b8e2e45 100644 --- a/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/tsconfig.json +++ b/x-pack/solutions/observability/packages/observability-ai/observability-ai-server/tsconfig.json @@ -18,6 +18,7 @@ "@kbn/observability-utils-server", "@kbn/traced-es-client", "@kbn/es-query", - "@kbn/ai-tools" + "@kbn/ai-tools", + "@kbn/std" ] } diff --git a/x-pack/solutions/observability/packages/utils-common/array/join_by_key.ts b/x-pack/solutions/observability/packages/utils-common/array/join_by_key.ts index f6b87b9da6c70..7c4ab27fe0640 100644 --- a/x-pack/solutions/observability/packages/utils-common/array/join_by_key.ts +++ b/x-pack/solutions/observability/packages/utils-common/array/join_by_key.ts @@ -7,7 +7,7 @@ import type { UnionToIntersection, ValuesType } from 'utility-types'; import { merge, castArray } from 'lodash'; -import stableStringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; export type JoinedReturnType< T extends Record, diff --git a/x-pack/solutions/observability/packages/utils-common/moon.yml b/x-pack/solutions/observability/packages/utils-common/moon.yml index d232998d84b11..2a17205fdf6a4 100644 --- a/x-pack/solutions/observability/packages/utils-common/moon.yml +++ b/x-pack/solutions/observability/packages/utils-common/moon.yml @@ -19,6 +19,7 @@ project: sourceRoot: x-pack/solutions/observability/packages/utils-common dependsOn: - '@kbn/es-query' + - '@kbn/std' tags: - shared-common - package diff --git a/x-pack/solutions/observability/packages/utils-common/tsconfig.json b/x-pack/solutions/observability/packages/utils-common/tsconfig.json index 29ece47c5142b..299f5d24382a4 100644 --- a/x-pack/solutions/observability/packages/utils-common/tsconfig.json +++ b/x-pack/solutions/observability/packages/utils-common/tsconfig.json @@ -17,5 +17,6 @@ ], "kbn_references": [ "@kbn/es-query", + "@kbn/std", ] } diff --git a/x-pack/solutions/observability/plugins/apm/common/utils/join_by_key/index.ts b/x-pack/solutions/observability/plugins/apm/common/utils/join_by_key/index.ts index f02723abce6de..a8ae4c8ef701b 100644 --- a/x-pack/solutions/observability/plugins/apm/common/utils/join_by_key/index.ts +++ b/x-pack/solutions/observability/plugins/apm/common/utils/join_by_key/index.ts @@ -7,7 +7,7 @@ import type { UnionToIntersection, ValuesType } from 'utility-types'; import { merge, castArray } from 'lodash'; -import stableStringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; /** * Joins a list of records by a given key. Key can be any type of value, from diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts index d87143c340100..b2d23590b7b2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts @@ -5,7 +5,6 @@ * 2.0. */ -import stringify from 'json-stable-stringify'; import type { RuleSchedule, SimpleRuleSchedule, @@ -20,6 +19,7 @@ import type { ThreeWayDiff, } from '../../../../../../common/api/detection_engine'; import type { FieldDiff } from '../../../model/rule_details/rule_field_diff'; +import { stringifyWithExpandedEmpties } from '../three_way_diff/comparison_side/utils'; export const sortAndStringifyJson = (fieldValue: unknown): string => { if (!fieldValue) { @@ -29,7 +29,7 @@ export const sortAndStringifyJson = (fieldValue: unknown): string => { if (typeof fieldValue === 'string') { return fieldValue; } - return stringify(fieldValue, { space: 2 }); + return stringifyWithExpandedEmpties(fieldValue); }; export const getFieldDiffsForDataSource = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx index 9b786d1e4c533..80a334e3f550f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { omit, pick } from 'lodash'; -import stringify from 'json-stable-stringify'; import { EuiSpacer, EuiPanel, @@ -21,6 +20,7 @@ import { normalizeMachineLearningJobIds } from '../../../../../common/detection_ import { filterEmptyThreats } from '../../../rule_creation_ui/pages/rule_creation/helpers'; import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; import { DiffView } from './json_diff/diff_view'; +import { stringifyWithExpandedEmpties } from './three_way_diff/comparison_side/utils'; /* Inclding these properties in diff display might be confusing to users. */ const HIDDEN_PROPERTIES: Array = [ @@ -60,9 +60,6 @@ const HIDDEN_PROPERTIES: Array = [ 'execution_summary', ]; -const sortAndStringifyJson = (jsObject: Record): string => - stringify(jsObject, { space: 2 }); - /** * Normalizes the rule object, making it suitable for comparison with another normalized rule. * @@ -144,8 +141,8 @@ export const RuleDiffTab = ({ ); return [ - sortAndStringifyJson(visibleOldRuleProperties), - sortAndStringifyJson(visibleNewRuleProperties), + stringifyWithExpandedEmpties(visibleOldRuleProperties), + stringifyWithExpandedEmpties(visibleNewRuleProperties), ]; }, [oldRule, newRule]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/utils.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/utils.ts index 7205aa1841e2b..9bc12ee847b35 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import stringify from 'json-stable-stringify'; +import { stableStringify } from '@kbn/std'; import { Version } from './versions_picker/constants'; import { ThreeWayDiffOutcome, @@ -49,7 +49,48 @@ export const stringifyToSortedJson = (fieldValue: unknown): string => { return fieldValue; } - return stringify(fieldValue, { space: 2 }); + return stringifyWithExpandedEmpties(fieldValue); +}; + +/** + * Stringifies a value to pretty-printed JSON with sorted keys, + * then expands inline empty arrays (`[]`) and objects (`{}`) to multi-line format. + * + * This expansion is needed because the diff view uses a line-based diff algorithm. + * Without it, a change from `[]` to a non-empty array (or object) shows as a full replacement + * (delete + insert) instead of a clean insertion, since the algorithm can't match + * the single-line `[]` against the opening `[` of a multi-line array. + * + * @param value - The value to stringify. + * @returns A pretty-printed JSON string with empty collections expanded to multi-line. + * + * @example + * // Input: { "author": [], "name": "Test" } + * // Output: + * // { + * // "author": [ + * // ], + * // "name": "Test" + * // } + */ +export const stringifyWithExpandedEmpties = (value: unknown): string => { + const jsonString = stableStringify(value, { space: 2 }); + + if (jsonString === '[]') { + return '[\n]'; + } + + if (jsonString === '{}') { + return '{\n}'; + } + + const expanded = jsonString + // Expand nested empty arrays from "[]" to "[\n]" + .replace(/^(\s*)(.*): \[\]/gm, '$1$2: [\n$1]') + // Expand nested empty objects from "{}" to "{\n}" + .replace(/^(\s*)(.*): \{\}/gm, '$1$2: {\n$1}'); + + return expanded; }; interface OptionDetails { diff --git a/yarn.lock b/yarn.lock index 921af5d41dffe..ef603d040e019 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14309,7 +14309,7 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/json-stable-stringify@1.0.32", "@types/json-stable-stringify@^1.0.32": +"@types/json-stable-stringify@^1.0.32": version "1.0.32" resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e" integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw== @@ -25108,18 +25108,13 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stable-stringify@1.0.1, json-stable-stringify@^1.0.1: +json-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8= dependencies: jsonify "~0.0.0" -json-stringify-pretty-compact@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-1.2.0.tgz#0bc316b5e6831c07041fc35612487fb4e9ab98b8" - integrity sha512-/11Pj1OyX814QMKO7K8l85SHPTr/KsFxHp8GE2zVa0BtJgGimDjXHfM3FhC7keQdWDea7+nXf+f1de7ATZcZkQ== - json-stringify-pretty-compact@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz#f71ef9d82ef16483a407869556588e91b681d9ab" @@ -25130,7 +25125,7 @@ json-stringify-pretty-compact@^4.0.0, json-stringify-pretty-compact@~4.0.0: resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz#cf4844770bddee3cb89a6170fe4b00eee5dbf1d4" integrity sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q== -json-stringify-safe@5.0.1, json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -31057,7 +31052,7 @@ safe-squel@5.12.5: dependencies: sql-escape-string "^1.1.0" -safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: +safe-stable-stringify@2.5.0, safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: version "2.5.0" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==