| 
 | 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 | +}  | 
0 commit comments