diff --git a/src/index.ts b/src/index.ts index b3a06a18cd..84abb6e22d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -394,6 +394,7 @@ export type { // Utilities for operating on GraphQL type schema and parsed sources. export { + Anonymizer, // Produce the GraphQL query recommended for a full schema introspection. // Accepts optional IntrospectionOptions. getIntrospectionQuery, diff --git a/src/utilities/Anonymizer.ts b/src/utilities/Anonymizer.ts new file mode 100644 index 0000000000..cb8f798d5c --- /dev/null +++ b/src/utilities/Anonymizer.ts @@ -0,0 +1,265 @@ +import { inspect } from '../jsutils/inspect'; +import { invariant } from '../jsutils/invariant'; + +import type { + ASTNode, + DocumentNode, + FloatValueNode, + IntValueNode, + NameNode, + StringValueNode, +} from '../language/ast'; +import { isNode } from '../language/ast'; +import { Kind } from '../language/kinds'; +import { parseValue } from '../language/parser'; +import type { ASTVisitor } from '../language/visitor'; +import { visit } from '../language/visitor'; + +import { GraphQLSchema } from '../type/schema'; +import { specifiedScalarTypes } from '../type/scalars'; + +import { TypeInfo, visitWithTypeInfo } from './TypeInfo'; + +interface AnonymizerOptions { + hashSalt: string; + hashFunction: (value: ArrayBuffer) => Promise; + safeListedSchema?: GraphQLSchema | null; +} + +export class Anonymizer { + private _valueMap: Map = new Map(); + private _safeListedSchema: GraphQLSchema | null; + private _hashFunction: (value: ArrayBuffer) => Promise; + private _hashSalt: string; + + constructor(options: AnonymizerOptions) { + const { + hashSalt, + hashFunction, + safeListedSchema = new GraphQLSchema({ types: specifiedScalarTypes }), + } = options; + + this._safeListedSchema = safeListedSchema; + this._hashSalt = hashSalt; + this._hashFunction = hashFunction; + } + + get [Symbol.toStringTag]() { + return 'Anonymizer'; + } + + async anonymizeDocumentNode( + documentAST: DocumentNode, + ): Promise { + const nodesToAnonymize: Array< + NameNode | StringValueNode | IntValueNode | FloatValueNode + > = []; + + const typeInfo = + this._safeListedSchema !== null + ? new TypeInfo(this._safeListedSchema) + : null; + const safeListedSchema = this._safeListedSchema; + const visitor: ASTVisitor = { + Name(node, key, parent) { + if ( + safeListedSchema === null || + typeInfo === null || + !isSafeListedName(safeListedSchema, typeInfo, node, key, parent) + ) { + nodesToAnonymize.push(node); + } + }, + StringValue(node) { + nodesToAnonymize.push(node); + }, + IntValue(node) { + nodesToAnonymize.push(node); + }, + FloatValue(node) { + nodesToAnonymize.push(node); + }, + }; + + const typeInfoVisitor = + typeInfo !== null ? visitWithTypeInfo(typeInfo, visitor) : visitor; + visit(documentAST, typeInfoVisitor); + + const anonymizedValues = await Promise.all( + nodesToAnonymize.map(({ value }) => this.anonymizeStringValue(value)), + ); + + const anonymizedMap = new Map(); + for (const [i, value] of anonymizedValues.entries()) { + const node = nodesToAnonymize[i]; + anonymizedMap.set(node, { ...node, loc: undefined, value }); + } + + return visit(documentAST, { + enter(node) { + const anonymizedNode = anonymizedMap.get(node); + if (anonymizedNode !== undefined) { + return anonymizedNode; + } + + return { + ...node, + // Remove `loc` on all nodes + loc: undefined, + }; + }, + }); + } + + async anonymizeValue(oldValue: unknown): Promise { + switch (typeof oldValue) { + case 'undefined': + case 'boolean': + return oldValue; + case 'number': + return Number.isFinite(oldValue) + ? Number(await this.anonymizeStringValue(oldValue.toString())) + : oldValue; + case 'bigint': + return BigInt(await this.anonymizeStringValue(oldValue.toString())); + case 'string': + return this.anonymizeStringValue(oldValue); + case 'symbol': + throw new TypeError('Can not anonymize symbol:' + inspect(oldValue)); + case 'function': + throw new TypeError('Can not anonymize function:' + inspect(oldValue)); + case 'object': + if (oldValue === null) { + return oldValue; + } + + if (Array.isArray(oldValue)) { + return Promise.all(oldValue.map((item) => this.anonymizeValue(item))); + } + + if (isPlainObject(oldValue)) { + return Object.fromEntries( + await Promise.all( + Object.entries(oldValue).map(async ([key, value]) => [ + await this.anonymizeStringValue(key), + await this.anonymizeValue(value), + ]), + ), + ); + } + throw new TypeError('Can not anonymize object:' + inspect(oldValue)); + } + } + + async anonymizeStringValue(oldValue: string): Promise { + const mappedNewValue = this._valueMap.get(oldValue); + if (mappedNewValue !== undefined) { + return mappedNewValue; + } + + const encoder = new TextEncoder(); + const hash = await this._hashFunction( + encoder.encode(this._hashSalt + oldValue).buffer, + ); + const newValue = generateNewValue(hash, oldValue); + this._valueMap.set(oldValue, newValue); + return newValue; + } +} + +function isSafeListedName( + safeListedSchema: GraphQLSchema, + typeInfo: TypeInfo, + nameNode: NameNode, + key: string | number | undefined, + parent: ASTNode | ReadonlyArray | undefined, +): boolean { + invariant(isNode(parent) && typeof key === 'string'); + + switch (parent.kind) { + case Kind.FIELD: + if (key === 'name') { + return typeInfo.getFieldDef() != null; + } + return false; + case Kind.ARGUMENT: + return typeInfo.getArgument() != null; + case Kind.OBJECT_FIELD: + return typeInfo.getInputType() != null; + case Kind.DIRECTIVE: + return typeInfo.getDirective() != null; + case Kind.NAMED_TYPE: + return safeListedSchema.getType(nameNode.value) !== undefined; + + case Kind.DIRECTIVE_DEFINITION: + return safeListedSchema.getDirective(nameNode.value) !== undefined; + case Kind.SCALAR_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_DEFINITION: + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.UNION_TYPE_DEFINITION: + case Kind.ENUM_TYPE_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_DEFINITION: + case Kind.SCALAR_TYPE_EXTENSION: + case Kind.OBJECT_TYPE_EXTENSION: + case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.UNION_TYPE_EXTENSION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + return safeListedSchema.getType(nameNode.value) !== undefined; + + case Kind.FIELD_DEFINITION: + case Kind.INPUT_VALUE_DEFINITION: + case Kind.ENUM_VALUE_DEFINITION: + return false; + + default: + return false; + } +} + +function isPlainObject(object: Object) { + const prototype = Object.getPrototypeOf(object); + return prototype === Object.prototype || prototype === null; +} + +function generateNewValue(hash: ArrayBuffer, oldValue: string): string { + const hashNumber = typedArrayToBigInt(hash); + try { + const parsedValue = parseValue(oldValue); + const first32Bit = hashNumber % BigInt(2 ** 32); + switch (parsedValue.kind) { + case Kind.INT: + return first32Bit.toString(); + case Kind.FLOAT: + return '0.' + first32Bit.toString(); + default: + } + } catch (_e) { + // ignore errors + } + return 'h_' + encodeBase62(hashNumber); +} + +function typedArrayToBigInt(array: ArrayBuffer): bigint { + let result = 0n; + const bytes = new Uint8Array(array); + for (const [index, byte] of bytes.entries()) { + result += BigInt(byte) << BigInt((bytes.length - 1 - index) * 8); + } + return result; +} + +const b62CharacterSet = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +function encodeBase62(number: bigint): string { + let result = ''; + + let leftOver = number; + do { + const reminder = leftOver % 62n; + result = b62CharacterSet.charAt(Number(reminder)) + result; + leftOver = (leftOver - reminder) / 62n; + } while (leftOver > 0n); + + return result; +} diff --git a/src/utilities/__tests__/Anonymizer-test.ts b/src/utilities/__tests__/Anonymizer-test.ts new file mode 100644 index 0000000000..0974a98da5 --- /dev/null +++ b/src/utilities/__tests__/Anonymizer-test.ts @@ -0,0 +1,64 @@ +import { webcrypto } from 'node:crypto'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { dedent } from '../../__testUtils__/dedent'; + +import { parse } from '../../language/parser'; +import { print } from '../../language/printer'; + +import { Anonymizer } from '../Anonymizer'; + +async function expectAnonymized(document: string) { + const anonymizer = new Anonymizer({ + hashSalt: 'graphql-js/', + hashFunction: (data) => webcrypto.subtle.digest('SHA-256', data), + }); + return expect(print(await anonymizer.anonymizeDocumentNode(parse(document)))); +} + +// test with schema, query => snapshot + test the same result +// test with invalid query due to arg mismatch (if argument replaced to the same became valid) +// test with coercion from string to int/float +// test with introspection query with type +describe('Anonymizer', () => { + it('can be Object.toStringified', () => { + const anonymizer = new Anonymizer({}); + + expect(Object.prototype.toString.call(anonymizer)).to.equal( + '[object Anonymizer]', + ); + }); + + it('work', async () => { + const anonymizer = new Anonymizer({ + hashSalt: 'graphql-js/', + hashFunction: (data) => webcrypto.subtle.digest('SHA-256', data), + }); + const hashed = await anonymizer.anonymizeStringValue('test'); + expect(hashed).to.equal('h_dBtROL5GGqP7VAoLl1CvQzrdgLUtOFRuqWCAhvWK8H0'); + }); + + it('work', async () => { + ( + await expectAnonymized(` + query TestQuery($arg: String) { + foo(arg: $arg) + bar { + baz @skip(if: false) + } + } + `) + ).to.equal(dedent` + query h_SJZgrQ0qER6XA2In0BvjgikiGyzS947FiPj0KVuWuqo($h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc: String) { + h_qbuzhEKLs429KQhe60wLZOP746k8mU69s6K3YN8sORO( + h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc: $h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc + ) + h_I7FTvKWpQa6jnQb8LeHDG2AlHuFUkDwt2n2c4WA7efV { + h_uua9CNLJ9uByOi4HQXucOoTNwop41vn8bLShit1u9in + } + } + `); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index d3ccffc917..43cc2e8300 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,5 @@ +export { Anonymizer } from './Anonymizer'; + // Produce the GraphQL query recommended for a full schema introspection. export { getIntrospectionQuery } from './getIntrospectionQuery'; diff --git a/tsconfig.json b/tsconfig.json index 4d0bc0adde..2aff4c6a5c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "benchmark/benchmark.ts" ], "compilerOptions": { - "lib": ["es2020"], + "lib": ["es2020", "dom"], "target": "es2020", "module": "commonjs", "moduleResolution": "node",