Skip to content

Instantly share code, notes, and snippets.

@boopathi
Last active January 29, 2022 14:16
Show Gist options
  • Save boopathi/82999b851b484911fe6f86832733c921 to your computer and use it in GitHub Desktop.
Save boopathi/82999b851b484911fe6f86832733c921 to your computer and use it in GitHub Desktop.
/**
* 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