Skip to content

Commit 61cf411

Browse files
committed
Add Anonymizer class
1 parent 699ec58 commit 61cf411

File tree

7 files changed

+337
-5
lines changed

7 files changed

+337
-5
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export type {
394394

395395
// Utilities for operating on GraphQL type schema and parsed sources.
396396
export {
397+
Anonymizer,
397398
// Produce the GraphQL query recommended for a full schema introspection.
398399
// Accepts optional IntrospectionOptions.
399400
getIntrospectionQuery,

src/jsutils/__tests__/capitalize-test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ describe('capitalize', () => {
1111
expect(capitalize('A')).to.equal('A');
1212

1313
expect(capitalize('ab')).to.equal('Ab');
14-
expect(capitalize('aB')).to.equal('Ab');
14+
expect(capitalize('aB')).to.equal('AB');
1515
expect(capitalize('Ab')).to.equal('Ab');
16-
expect(capitalize('AB')).to.equal('Ab');
16+
expect(capitalize('AB')).to.equal('AB');
1717

1818
expect(capitalize('platypus')).to.equal('Platypus');
19-
expect(capitalize('PLATYPUS')).to.equal('Platypus');
19+
expect(capitalize('uniquePlatypus')).to.equal('UniquePlatypus');
2020
});
2121
});

src/jsutils/capitalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
* Converts the first character of string to upper case and the remaining to lower case.
33
*/
44
export function capitalize(str: string): string {
5-
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
5+
return str.charAt(0).toUpperCase() + str.slice(1);
66
}

src/utilities/Anonymizer.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { inspect } from '../jsutils/inspect';
2+
import { invariant } from '../jsutils/invariant';
3+
4+
import type {
5+
ASTNode,
6+
DocumentNode,
7+
FloatValueNode,
8+
IntValueNode,
9+
NameNode,
10+
StringValueNode,
11+
} from '../language/ast';
12+
import { isNode } from '../language/ast';
13+
import { Kind } from '../language/kinds';
14+
import { parseValue } from '../language/parser';
15+
import type { ASTVisitor } from '../language/visitor';
16+
import { visit } from '../language/visitor';
17+
18+
import { GraphQLSchema } from '../type/schema';
19+
import { specifiedScalarTypes } from '../type/scalars';
20+
21+
import { TypeInfo, visitWithTypeInfo } from './TypeInfo';
22+
23+
interface AnonymizerOptions {
24+
hashSalt: string;
25+
hashFunction: (value: ArrayBuffer) => Promise<ArrayBuffer>;
26+
safeListedSchema?: GraphQLSchema | null;
27+
}
28+
29+
export class Anonymizer {
30+
private _valueMap: Map<string, string> = new Map();
31+
private _safeListedSchema: GraphQLSchema | null;
32+
private _hashFunction: (value: ArrayBuffer) => Promise<ArrayBuffer>;
33+
private _hashSalt: string;
34+
35+
constructor(options: AnonymizerOptions) {
36+
const {
37+
hashSalt,
38+
hashFunction,
39+
safeListedSchema = new GraphQLSchema({ types: specifiedScalarTypes }),
40+
} = options;
41+
42+
this._safeListedSchema = safeListedSchema;
43+
this._hashSalt = hashSalt;
44+
this._hashFunction = hashFunction;
45+
}
46+
47+
get [Symbol.toStringTag]() {
48+
return 'Anonymizer';
49+
}
50+
51+
async anonymizeDocumentNode(
52+
documentAST: DocumentNode,
53+
): Promise<DocumentNode> {
54+
const nodesToAnonymize: Array<
55+
NameNode | StringValueNode | IntValueNode | FloatValueNode
56+
> = [];
57+
58+
const typeInfo =
59+
this._safeListedSchema !== null
60+
? new TypeInfo(this._safeListedSchema)
61+
: null;
62+
const safeListedSchema = this._safeListedSchema;
63+
const visitor: ASTVisitor = {
64+
Name(node, key, parent) {
65+
if (
66+
safeListedSchema === null ||
67+
typeInfo === null ||
68+
!isSafeListedName(safeListedSchema, typeInfo, node, key, parent)
69+
) {
70+
nodesToAnonymize.push(node);
71+
}
72+
},
73+
StringValue(node) {
74+
nodesToAnonymize.push(node);
75+
},
76+
IntValue(node) {
77+
nodesToAnonymize.push(node);
78+
},
79+
FloatValue(node) {
80+
nodesToAnonymize.push(node);
81+
},
82+
};
83+
84+
const typeInfoVisitor =
85+
typeInfo !== null ? visitWithTypeInfo(typeInfo, visitor) : visitor;
86+
visit(documentAST, typeInfoVisitor);
87+
88+
const anonymizedValues = await Promise.all(
89+
nodesToAnonymize.map(({ value }) => this.anonymizeStringValue(value)),
90+
);
91+
92+
const anonymizedMap = new Map<ASTNode, ASTNode>();
93+
for (const [i, value] of anonymizedValues.entries()) {
94+
const node = nodesToAnonymize[i];
95+
anonymizedMap.set(node, { ...node, loc: undefined, value });
96+
}
97+
98+
return visit(documentAST, {
99+
enter(node) {
100+
const anonymizedNode = anonymizedMap.get(node);
101+
if (anonymizedNode !== undefined) {
102+
return anonymizedNode;
103+
}
104+
105+
return {
106+
...node,
107+
// Remove `loc` on all nodes
108+
loc: undefined,
109+
};
110+
},
111+
});
112+
}
113+
114+
async anonymizeValue(oldValue: unknown): Promise<unknown> {
115+
switch (typeof oldValue) {
116+
case 'undefined':
117+
case 'boolean':
118+
return oldValue;
119+
case 'number':
120+
return Number.isFinite(oldValue)
121+
? Number(await this.anonymizeStringValue(oldValue.toString()))
122+
: oldValue;
123+
case 'bigint':
124+
return BigInt(await this.anonymizeStringValue(oldValue.toString()));
125+
case 'string':
126+
return this.anonymizeStringValue(oldValue);
127+
case 'symbol':
128+
throw new TypeError('Can not anonymize symbol:' + inspect(oldValue));
129+
case 'function':
130+
throw new TypeError('Can not anonymize function:' + inspect(oldValue));
131+
case 'object':
132+
if (oldValue === null) {
133+
return oldValue;
134+
}
135+
136+
if (Array.isArray(oldValue)) {
137+
return Promise.all(oldValue.map((item) => this.anonymizeValue(item)));
138+
}
139+
140+
if (isPlainObject(oldValue)) {
141+
return Object.fromEntries(
142+
await Promise.all(
143+
Object.entries(oldValue).map(async ([key, value]) => [
144+
await this.anonymizeStringValue(key),
145+
await this.anonymizeValue(value),
146+
]),
147+
),
148+
);
149+
}
150+
throw new TypeError('Can not anonymize object:' + inspect(oldValue));
151+
}
152+
}
153+
154+
async anonymizeStringValue(oldValue: string): Promise<string> {
155+
const mappedNewValue = this._valueMap.get(oldValue);
156+
if (mappedNewValue !== undefined) {
157+
return mappedNewValue;
158+
}
159+
160+
const encoder = new TextEncoder();
161+
const hash = await this._hashFunction(
162+
encoder.encode(this._hashSalt + oldValue).buffer,
163+
);
164+
const newValue = generateNewValue(hash, oldValue);
165+
this._valueMap.set(oldValue, newValue);
166+
return newValue;
167+
}
168+
}
169+
170+
function isSafeListedName(
171+
safeListedSchema: GraphQLSchema,
172+
typeInfo: TypeInfo,
173+
nameNode: NameNode,
174+
key: string | number | undefined,
175+
parent: ASTNode | ReadonlyArray<ASTNode> | undefined,
176+
): boolean {
177+
invariant(isNode(parent) && typeof key === 'string');
178+
179+
switch (parent.kind) {
180+
case Kind.FIELD:
181+
if (key === 'name') {
182+
return typeInfo.getFieldDef() != null;
183+
}
184+
return false;
185+
case Kind.ARGUMENT:
186+
return typeInfo.getArgument() != null;
187+
case Kind.OBJECT_FIELD:
188+
return typeInfo.getInputType() != null;
189+
case Kind.DIRECTIVE:
190+
return typeInfo.getDirective() != null;
191+
case Kind.NAMED_TYPE:
192+
return safeListedSchema.getType(nameNode.value) !== undefined;
193+
194+
case Kind.DIRECTIVE_DEFINITION:
195+
return safeListedSchema.getDirective(nameNode.value) !== undefined;
196+
case Kind.SCALAR_TYPE_DEFINITION:
197+
case Kind.OBJECT_TYPE_DEFINITION:
198+
case Kind.INTERFACE_TYPE_DEFINITION:
199+
case Kind.UNION_TYPE_DEFINITION:
200+
case Kind.ENUM_TYPE_DEFINITION:
201+
case Kind.INPUT_OBJECT_TYPE_DEFINITION:
202+
case Kind.SCALAR_TYPE_EXTENSION:
203+
case Kind.OBJECT_TYPE_EXTENSION:
204+
case Kind.INTERFACE_TYPE_EXTENSION:
205+
case Kind.UNION_TYPE_EXTENSION:
206+
case Kind.ENUM_TYPE_EXTENSION:
207+
case Kind.INPUT_OBJECT_TYPE_EXTENSION:
208+
return safeListedSchema.getType(nameNode.value) !== undefined;
209+
210+
case Kind.FIELD_DEFINITION:
211+
case Kind.INPUT_VALUE_DEFINITION:
212+
case Kind.ENUM_VALUE_DEFINITION:
213+
return false;
214+
215+
default:
216+
return false;
217+
}
218+
}
219+
220+
function isPlainObject(object: Object) {
221+
const prototype = Object.getPrototypeOf(object);
222+
return prototype === Object.prototype || prototype === null;
223+
}
224+
225+
function generateNewValue(hash: ArrayBuffer, oldValue: string): string {
226+
const hashNumber = typedArrayToBigInt(hash);
227+
try {
228+
const parsedValue = parseValue(oldValue);
229+
const first32Bit = hashNumber % BigInt(2 ** 32);
230+
switch (parsedValue.kind) {
231+
case Kind.INT:
232+
return first32Bit.toString();
233+
case Kind.FLOAT:
234+
return '0.' + first32Bit.toString();
235+
default:
236+
}
237+
} catch (_e) {
238+
// ignore errors
239+
}
240+
return 'h_' + encodeBase62(hashNumber);
241+
}
242+
243+
function typedArrayToBigInt(array: ArrayBuffer): bigint {
244+
let result = 0n;
245+
const bytes = new Uint8Array(array);
246+
for (const [index, byte] of bytes.entries()) {
247+
result += BigInt(byte) << BigInt((bytes.length - 1 - index) * 8);
248+
}
249+
return result;
250+
}
251+
252+
const b62CharacterSet =
253+
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
254+
function encodeBase62(number: bigint): string {
255+
let result = '';
256+
257+
let leftOver = number;
258+
do {
259+
const reminder = leftOver % 62n;
260+
result = b62CharacterSet.charAt(Number(reminder)) + result;
261+
leftOver = (leftOver - reminder) / 62n;
262+
} while (leftOver > 0n);
263+
264+
return result;
265+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { webcrypto } from 'node:crypto';
2+
3+
import { expect } from 'chai';
4+
import { describe, it } from 'mocha';
5+
6+
import { dedent } from '../../__testUtils__/dedent';
7+
8+
import { parse } from '../../language/parser';
9+
import { print } from '../../language/printer';
10+
11+
import { Anonymizer } from '../Anonymizer';
12+
13+
async function expectAnonymized(document: string) {
14+
const anonymizer = new Anonymizer({
15+
hashSalt: 'graphql-js/',
16+
hashFunction: (data) => webcrypto.subtle.digest('SHA-256', data),
17+
});
18+
return expect(print(await anonymizer.anonymizeDocumentNode(parse(document))));
19+
}
20+
21+
// test with schema, query => snapshot + test the same result
22+
// test with invalid query due to arg mismatch (if argument replaced to the same became valid)
23+
// test with coercion from string to int/float
24+
// test with introspection query with type
25+
describe('Anonymizer', () => {
26+
it('can be Object.toStringified', () => {
27+
const anonymizer = new Anonymizer({});
28+
29+
expect(Object.prototype.toString.call(anonymizer)).to.equal(
30+
'[object Anonymizer]',
31+
);
32+
});
33+
34+
it('work', async () => {
35+
const anonymizer = new Anonymizer({
36+
hashSalt: 'graphql-js/',
37+
hashFunction: (data) => webcrypto.subtle.digest('SHA-256', data),
38+
});
39+
const hashed = await anonymizer.anonymizeStringValue('test');
40+
expect(hashed).to.equal('h_dBtROL5GGqP7VAoLl1CvQzrdgLUtOFRuqWCAhvWK8H0');
41+
});
42+
43+
it('work', async () => {
44+
(
45+
await expectAnonymized(`
46+
query TestQuery($arg: String) {
47+
foo(arg: $arg)
48+
bar {
49+
baz @skip(if: false)
50+
}
51+
}
52+
`)
53+
).to.equal(dedent`
54+
query h_SJZgrQ0qER6XA2In0BvjgikiGyzS947FiPj0KVuWuqo($h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc: String) {
55+
h_qbuzhEKLs429KQhe60wLZOP746k8mU69s6K3YN8sORO(
56+
h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc: $h_nVaZrh9Oups9oZLouxgQFpHNTo1kxlaa3dI8D5PBIdc
57+
)
58+
h_I7FTvKWpQa6jnQb8LeHDG2AlHuFUkDwt2n2c4WA7efV {
59+
h_uua9CNLJ9uByOi4HQXucOoTNwop41vn8bLShit1u9in
60+
}
61+
}
62+
`);
63+
});
64+
});

src/utilities/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export { Anonymizer } from './Anonymizer';
2+
13
// Produce the GraphQL query recommended for a full schema introspection.
24
export { getIntrospectionQuery } from './getIntrospectionQuery';
35

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"benchmark/benchmark.ts"
77
],
88
"compilerOptions": {
9-
"lib": ["es2020"],
9+
"lib": ["es2020", "dom"],
1010
"target": "es2020",
1111
"module": "commonjs",
1212
"moduleResolution": "node",

0 commit comments

Comments
 (0)