-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Thierry DEGREMONT
committed
Sep 16, 2024
1 parent
16f2d63
commit 9385c8d
Showing
13 changed files
with
1,341 additions
and
167 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
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
111 changes: 111 additions & 0 deletions
111
packages/graphql-mesh/custom-plugins/monitor-envelop.ts
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
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) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
|
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
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) | ||
} | ||
} | ||
} | ||
} | ||
} |
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
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 not shown.
Binary file modified
BIN
-21 Bytes
(99%)
packages/graphql-mesh/local-pkg/inject-additional-transforms-1.0.0.tgz
Binary file not shown.
Oops, something went wrong.