Skip to content

Commit

Permalink
add plugins monitoring
Browse files Browse the repository at this point in the history
  • Loading branch information
Thierry DEGREMONT committed Sep 2, 2024
1 parent 08248a2 commit 0776c88
Show file tree
Hide file tree
Showing 13 changed files with 1,339 additions and 176 deletions.
388 changes: 275 additions & 113 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"postinstall": "npm run postinstall -w graphql-mesh && patch-package && npm run generate:mesh:lock",
"build:local:packages": "concurrently \"npm run pack -w directive-spl\" \"npm run pack -w inject-additional-transforms\"",
"preinstall": "npm run build:local:packages",
"start": "npm start -w graphql-mesh"
},
"start": "npm start -w graphql-mesh",
"startmesh": "npm run startmesh -w graphql-mesh",
"server": "npm run server -w graphql-mesh"
},
"devDependencies": {
"concurrently": "^8.2.2",
"patch-package": "^8.0.0"
Expand Down
111 changes: 111 additions & 0 deletions packages/graphql-mesh/custom-plugins/monitor-envelop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type Plugin } from '@envelop/core'
import { Logger } from '../utils/logger'
import { NoSchemaIntrospectionCustomRule } from 'graphql';
import { GraphQLError } from 'graphql';
/**
* monitor plugin in order to get event contextual log and add some security rules
* useful to
* - log the graphql Query event
* - add desabled introspection validation rule
* - remove suggestion message
* - log the execute result summary with executes duration
* - remove not allowed introspection in result
*/

const formatter = (error: GraphQLError, mask: string): GraphQLError => {
if (error instanceof GraphQLError) {
error.message = error.message.replace(/Did you mean ".+"/g, mask);
}
return error as GraphQLError;
};
export default ({ options }): Plugin => {
// not allow by default
// do not enabled allowIntrospection in production
const allowIntrospection = process.env['IS_PROUCTION_ENV'] != 'true' && (options?.introspection?.allow || process.env['ENABLED_INTROSPECTION'] || false)
// low info in log by default
const resultLogInfoLevel = options?.resultLogInfoLevel ? options.resultLogInfoLevel : "low"
const denyIntrospectionHeaderName = options?.introspection?.denyHeaderName || null
const denyIntrospectionHeaderValue = options?.introspection?.denyHeaderValue || null
const allowIntrospectionHeaderName = options?.introspection?.allowHeaderName || null
const allowIntrospectionHeaderValue = options?.introspection?.allowHeaderValue || null
const isMaskSuggestion = options?.maskSuggestion?.enabled || false
const maskSuggestionMessage = options?.maskSuggestion?.message || ""
return {
onParse({ context }) {
if (options.logOnParse) {
Logger.graphqlQuery(context['request']['headers'], context['params'])
}
},

onValidate: ({ addValidationRule, context }) => {
const headers = context['request'].headers
let deny = true
/*
allowIntrospection=false : intropection deny for all
denyIntrospectionHeaderName : name of the header to check to deny introspection is deny ex plublic proxy header
allowIntrospectionHeaderName : name of the header allow if this header and value is presents
*/
// if introspection not allow
if (allowIntrospection) {
// intropection may be allow
deny = false
// is existed a header to deny introspection
if (denyIntrospectionHeaderName) {
if (headers.get(denyIntrospectionHeaderName)) {
if (headers.get(denyIntrospectionHeaderName).includes(denyIntrospectionHeaderValue)) {
Logger.denyIntrospection("onValidate", "deny by headers " + denyIntrospectionHeaderName + ": " + headers.get(denyIntrospectionHeaderName), headers)
deny = true
}
}
}
// is existed a header mandatory to allow introspection
if (allowIntrospectionHeaderName) {
deny = true
if (headers.get(allowIntrospectionHeaderName)) {
if (headers.get(allowIntrospectionHeaderName).includes(allowIntrospectionHeaderValue)) {
Logger.allowIntrospection("onValidate", "allow by headers " + allowIntrospectionHeaderName + ": " + headers.get(allowIntrospectionHeaderName).substring(0, 4) + "...", headers)
deny = false
} else {
Logger.denyIntrospection("onValidate", "deny by bad header value " + allowIntrospectionHeaderName + ": " + headers.get(allowIntrospectionHeaderName).substring(0, 4) + "...", headers)
}
} else {
Logger.denyIntrospection("onValidate", "deny by no header " + allowIntrospectionHeaderName, headers)
}
}
}
if (deny) {
addValidationRule(NoSchemaIntrospectionCustomRule)
}

return function onValidateEnd({ valid, result, setResult }) {
if (isMaskSuggestion && !valid) {
setResult(result.map((error: GraphQLError) => formatter(error, maskSuggestionMessage)));
}
};
},

onExecute(/*{ args, extendContext }*/) {
let timestampDebut = new Date().getTime()
return {
before() {

timestampDebut = new Date().getTime()
},
onExecuteDone({ result, args }) {
const timestampDone = new Date().getTime();
// short cut to desabled introspection response in case of bad configuration rule
if (!allowIntrospection && args.contextValue['params'].query.includes('__schema')) {
result['data'] = {}
result['errors'] = [{ message: "Fordidden" }]
Logger.error('SECU', 'onExecute', 'Introspection query deteted not allowed', args.contextValue['params'])
}
if (options.loOnExecuteDone) {
Logger.endExec(args.contextValue['request']['headers'], result, timestampDone - timestampDebut, resultLogInfoLevel)
}
}
}
}
}
}


65 changes: 65 additions & 0 deletions packages/graphql-mesh/custom-plugins/monitor-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { type Plugin } from '@envelop/core';

import { Logger } from '../utils/logger'

/**
* monitor fetch source
* use to :
* - add log event for each fetch like, url, response status, duration
*/

export default ({ options }) => {
return <Plugin>{
onFetch({ context, info }) {
if (!info) {
Logger.warn("noFeychInfo", "onFetch", "no info in on fetch")
return;
}
const start = Date.now();
let rawSource = context[info.sourceName]
let description = info.parentType._fields[info.path.key].description

return (fetch: any) => {
if (options.logOnFetch) {
const duration = Date.now() - start;
let fetchInfo = {}
let httpStatus = null
let url = null
if (options.fullFetchInfo) {
fetchInfo = {
fieldName: info.fieldName,
sourceName: info.sourceName,
pathKey: info.path.key,
operation: info.operation.name,
variables: info.variables,
endpoint: rawSource.rawSource.handler.config.endpoint,
description: description
}
} else {
fetchInfo = {
fieldName: info.fieldName,
pathKey: info.path.key,
operation: info.operation.name,
variables: info.variableValues,
endpoint: rawSource.rawSource.handler.config.endpoint,
}
}
//const fetchResponseInfo = {}
if (fetch.response) {

httpStatus = fetch.response.status
url = fetch.response.url
const options = fetch.response.options
if (options) {
fetchInfo['options'] = {
requestId: options.headers['x-request-id'],
server: options.headers['server']
}
}
}
Logger.onFetch(context.request, url, httpStatus, duration, fetchInfo)
}
}
}
}
}
118 changes: 118 additions & 0 deletions packages/graphql-mesh/custom-plugins/monitor-yoga.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Plugin } from 'graphql-yoga'
import { Logger } from '../utils/logger'
import { GraphQLError } from 'graphql'
import { v4 as uuidv4 } from 'uuid'
/**
* monitor plugin in order to get event contextual log and add some security rules
* useful to :
* - log new request comming event
* - add request timestamp in headers to get duration time
* - monitor instropection request
* - mask error in result is required ( ex in production )
* - log response sumary event
* - remove a eventualy not allowed instropection data in result
*/

export function useYagaMonitoring({ options }): Plugin {
const isMaskErrors = options?.maskError?.enabled || process.env['MASK_ERRORS'] || false
// filter error in production anyway
const isFilterError = options?.filterError?.enabled || process.env['FILTER_ERRORS'] == 'true' || process.env['IS_PROUCTION_ENV'] == 'true' || false
const errorMaskMessage = options?.maskError?.message ? options.maskError.message : "something goes wrong"
const responseLogInfoLevel = options?.responseLogInfoLevel ? options.responseLogInfoLevel : "low"
const resultLogInfoLevel = options?.resultLogInfoLevel ? options.resultLogInfoLevel : "medium"

return {
onRequest({ request/*, fetchAPI, endResponse */ }) {
if (options.LogOnRequest) {
// log only graphql request, avoid log other request like metric requests
if (request.url.includes("/graphql")) {
Logger.onRequest(request)
}
}

// add resuestTimestamp in headers
const timestamp = new Date().getTime();
request.headers.append("requestTimestamp", String(timestamp))

// add x-request-id in header if not present
if (!request.headers.get('x-request-id')) {
request.headers.append("x-request-id", uuidv4())
}

},
onRequestParse(args) {
const beforeTimestamp = new Date().getTime();
let requestHeaders = args.request.headers
return {
onRequestParseDone(nRequestParseDoneEventPayload) {
const timestamp = new Date().getTime();
if (options.logOnRequestParseDone) {
Logger.onRequestParseDone(requestHeaders, nRequestParseDoneEventPayload.requestParserResult['query'], nRequestParseDoneEventPayload.requestParserResult['operationName'], nRequestParseDoneEventPayload.requestParserResult['variables'], timestamp - beforeTimestamp)
}
if (nRequestParseDoneEventPayload.requestParserResult['query'].includes('__schema')) {
Logger.introspection( requestHeaders, nRequestParseDoneEventPayload.requestParserResult['query'])
}
}
}
},
onResultProcess(args) {
if (options.logOnResultProcess) {
// calculate duration from request timestamp
let requestTimestamp: number = 0
if (args.request['headers']) {
const requestTimestampString = args.request['headers'].get('requesttimestamp')
if (requestTimestampString) {
requestTimestamp = parseInt(requestTimestampString)
}
}
const responseTimestamp = new Date().getTime();
Logger.onResultProcess(args.request, args.result, requestTimestamp > 0 ? responseTimestamp - requestTimestamp : 0, resultLogInfoLevel)
}
// if we want to replace all message with a generic message
if (isMaskErrors) {
if (args.result['errors']) {
let errors = args.result['errors']
for (let i = 0; i < errors.length; i++) {
errors[i] = errorMaskMessage
}
}
} else {
// if we want to filter error to only return the message, don't return extend information like stacktrace
if (isFilterError) {
if (args.result['errors']) {
let errors = args.result['errors']
for (let i = 0; i < errors.length; i++) {
errors[i] = new GraphQLError(filterErrorMessage(errors[i]['message']))
}

}

}
}
},

onResponse({ request, response }) {
// dont log options http
if (request.method != 'OPTIONS') {
if (options.logOnResponse) {
// only log graphql request don't log metrics or other requests
if (request.url.includes("/graphql")) {
Logger.onResponse(request, response, responseLogInfoLevel)
}
}
}
}
}
}

/** filterErrorMessage
* use to filter error message :
* - remove disabled introspection
* todo: add other filter rules to remove non expecting message
*/
function filterErrorMessage(message: string) {
if (message.includes("introspection has been disabled")) {
return "forbidden"
}
return message
}
Binary file modified packages/graphql-mesh/local-pkg/directive-spl-1.0.0.tgz
Binary file not shown.
Binary file not shown.
Loading

0 comments on commit 0776c88

Please sign in to comment.