Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
265 changes: 265 additions & 0 deletions src/utilities/Anonymizer.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>;
safeListedSchema?: GraphQLSchema | null;
}

export class Anonymizer {
private _valueMap: Map<string, string> = new Map();
private _safeListedSchema: GraphQLSchema | null;
private _hashFunction: (value: ArrayBuffer) => Promise<ArrayBuffer>;
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<DocumentNode> {
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<ASTNode, ASTNode>();
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<unknown> {
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<string> {
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<ASTNode> | 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;
}
64 changes: 64 additions & 0 deletions src/utilities/__tests__/Anonymizer-test.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
`);
});
});
2 changes: 2 additions & 0 deletions src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { Anonymizer } from './Anonymizer';

// Produce the GraphQL query recommended for a full schema introspection.
export { getIntrospectionQuery } from './getIntrospectionQuery';

Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"benchmark/benchmark.ts"
],
"compilerOptions": {
"lib": ["es2020"],
"lib": ["es2020", "dom"],
"target": "es2020",
"module": "commonjs",
"moduleResolution": "node",
Expand Down