-
-
Save boopathi/82999b851b484911fe6f86832733c921 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* sensitive-heuristics is a heuristics based rule to prevent potential | |
* security risks in the schema - i.e. to prevent logging of sensitive | |
* customer information like name, email, password, phone number, etc... | |
* | |
* We mark such inputs with `@sensitive` directive, which would | |
* then be used in Runtime to prevent such logging. | |
*/ | |
"use strict"; | |
const { | |
ValidationError | |
} = require("graphql-schema-linter/lib/validation_error"); | |
const { Kind } = require("graphql"); | |
const NAME = "sensitive-heuristics"; | |
const SENSITIVE_DIRECTIVE_NAME = "sensitive"; | |
/** | |
* Input field names that contain these words as substrings MUST be | |
* marked as sensitive | |
*/ | |
const SENSITIVE_WORDS = [ | |
"customer", | |
"email", | |
"phone", | |
"password", | |
"address", | |
"firstname", | |
"lastname", | |
"salutation", | |
"street", | |
"zip", | |
"bank", | |
"iban", | |
"bic", | |
"account", | |
"owner", | |
"orderid", | |
"ordernumber" | |
]; | |
exports.SensitiveHeuristics = function SensitiveHeuristics(context) { | |
const schema = context.getSchema(); | |
const scalars = getScalarTypes(schema); | |
return { | |
FieldDefinition(node, _1, _2, _3, ancestors) { | |
if (node.arguments == null || node.arguments.length < 1) return; | |
/** | |
* To prevent endless recursion while traversing Input Objects. | |
* | |
* In graphql, recursive input type definitions are possible. | |
* | |
* The visited Set would contain references to the ASTNode of the | |
* Input Object Definitions | |
*/ | |
const visited = new Set(); | |
// This is only for error report and does not contribute to actual computation | |
const parentTypename = ancestors[ancestors.length - 1].name.value; | |
for (const arg of node.arguments) { | |
try { | |
assertDeepSensitiveFieldName({ | |
scalars, | |
schema, | |
arg, | |
path: [ | |
{ | |
name: node.name.value, | |
typename: resolveType(node.type).name.value, | |
/** | |
* This is a property that exists only in the first item of the | |
* path array because it is used in error messages. Because the | |
* @sensitive directive can only be used in the Field argument, | |
* the parent typename is useful in error messages. | |
* | |
* Check formatPath() for more details | |
*/ | |
parentTypename, | |
/** | |
* This is also a property that exists only on the first item of | |
* the path array because, it is used to compute the directive | |
* because of the same reasons - @sensitive directive can only | |
* be used in the Field argument. | |
*/ | |
arg | |
} | |
], | |
visited | |
}); | |
} catch (e) { | |
if (e instanceof HasSensitiveWordError) { | |
const { directiveLocation, errorLocation } = formatPath(e.path); | |
context.reportError( | |
new ValidationError( | |
NAME, | |
`Must mark '${directiveLocation}' as '@sensitive'. It contains the word '${e.word}' at '${errorLocation}'`, | |
[e.path[0].arg] | |
) | |
); | |
} else { | |
// In-case of any other error, it's a bug in this plugin | |
throw e; | |
} | |
} | |
} | |
} | |
}; | |
}; | |
/** | |
* Get all scalar types from the current schema - | |
* | |
* 1. Internal scalars like ID, Int, etc..., | |
* 2. Custom scalars defined with `scalar Foo`, like DateTime, FKGTag, etc... | |
*/ | |
function getScalarTypes(schema) { | |
const typemap = schema.getTypeMap(); | |
const internalScalars = ["ID", "Int", "Float", "Boolean", "String"]; | |
const customScalars = []; | |
for (let typename in typemap) { | |
if ( | |
// ignore internal type nodes | |
typemap[typename].astNode != null && | |
// Custom Scalar type nodes | |
typemap[typename].astNode.kind === Kind.SCALAR_TYPE_DEFINITION | |
) { | |
customScalars.push(typename); | |
} | |
} | |
return [...internalScalars, ...customScalars]; | |
} | |
/** | |
* If the ASTNode @param arg contains a sensitive word, and | |
* does NOT contain @senstive directive, throw a custom error | |
*/ | |
function assertSensitiveFieldName(arg, path) { | |
const foundSensitiveWord = SENSITIVE_WORDS.find(word => | |
arg.name.value.toLowerCase().includes(word.toLowerCase()) | |
); | |
if (foundSensitiveWord != null) { | |
/** | |
* The path[0].arg is used instead of arg because of the location | |
* of where a @sensitive directive can be used. It can only be used | |
* in the input argument of a FieldDefinition and not any | |
* InputFieldDefinition. | |
*/ | |
const hasSensitiveDirective = path[0].arg.directives.some( | |
dir => dir.name.value === SENSITIVE_DIRECTIVE_NAME | |
); | |
if (!hasSensitiveDirective) | |
throw new HasSensitiveWordError(foundSensitiveWord, arg, path); | |
} | |
} | |
/** | |
* Recurse through the input types | |
*/ | |
function assertDeepSensitiveFieldName({ scalars, schema, arg, path, visited }) { | |
const typename = resolveType(arg.type).name.value; | |
const nextPath = [...path, { name: arg.name.value, typename }]; | |
/** | |
* For the current input field definition represented by arg, | |
* assert the heuristics follows @sensitive directive. This function | |
* throws and stops execution of further analysis. We only need one | |
* param to mark the input param as sensitive. This is because of | |
* the restriction of the directive itself that it can be used only | |
* in the input argument defintion and not on a InputFieldDefinition. | |
*/ | |
assertSensitiveFieldName(arg, nextPath); | |
/** | |
* Internal Types like ID, String, and custom scalars do not | |
* need deeper search. Also, internal types do not have astNode | |
* or a type class (schema.getType -> null) | |
*/ | |
if (scalars.includes(typename)) return; | |
const type = schema.getType(typename); | |
if (!type.astNode) { | |
throw new Error( | |
`AST node not found for ${type}.` | |
); | |
} | |
/** | |
* Prevent endless recursion | |
*/ | |
if (visited.has(type.astNode)) return; | |
visited.add(type.astNode); | |
/** | |
* If the type is anything other than an Input Object definition | |
* defined by `input Foo {}`, discontinue as there are no fields | |
* to go deeper in the search | |
*/ | |
if (type.astNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) return; | |
/** | |
* For each of the fields in the input object, recursively call | |
* the same function | |
*/ | |
const fields = type.getFields(); | |
for (let field in fields) { | |
if (hop(fields, field)) { | |
assertDeepSensitiveFieldName({ | |
scalars, | |
schema, | |
arg: fields[field].astNode, | |
path: nextPath, | |
visited | |
}); | |
} | |
} | |
} | |
/** | |
* A custom error to indicate a node has sensitive field name, | |
* and is not marked with @sensitive directive. | |
*/ | |
class HasSensitiveWordError extends Error { | |
constructor(word, arg, path) { | |
super(`Has sensitive word ${word}`); | |
this.word = word; | |
this.arg = arg; | |
this.path = path; | |
} | |
} | |
/** | |
* Returns a formatted path to print in error messages | |
* | |
* `directiveLocation` refers to the place where @sensitive | |
* directive must be placed. | |
* | |
* `errorLocation` refers to the path to the field where a | |
* word that doesn't conform to sensitive input is found. | |
* | |
*/ | |
function formatPath(path) { | |
const print = p => p.map(({ name }) => name).join("."); | |
return { | |
directiveLocation: path[0].parentTypename + "." + print(path.slice(0, 2)), | |
errorLocation: print(path.slice(1)) | |
}; | |
} | |
/** | |
* Resolve Type unwraps list and non-null types | |
* | |
* Resolves `[Foo!]!` to `Foo` | |
*/ | |
function resolveType(type) { | |
if (type.kind === Kind.NON_NULL_TYPE) { | |
return resolveType(type.type); | |
} | |
if (type.kind === Kind.LIST_TYPE) { | |
return resolveType(type.type); | |
} | |
return type; | |
} | |
function hop(o, key) { | |
return Object.prototype.hasOwnProperty.call(o, key); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment