@hclsoftware/secagent
Version:
IAST agent
447 lines (395 loc) • 18 kB
JavaScript
//IASTIGNORE
/* eslint-disable no-new-wrappers */
/*
* ****************************************************
* Licensed Materials - Property of HCL.
* (c) Copyright HCL Technologies Ltd. 2017, 2025.
* Note to U.S. Government Users *Restricted Rights.
* ****************************************************
*/
// Express documentation: https://expressjs.com/en/4x/api.html#req
// Express request and response are enhanced version of node.js built in library and support all their methods and fields: https://nodejs.org/api/http.html
// hook the code that gets called each time a new module is required (imported)
// so that we can return another hooked module when the express module
// is required. We want to hook the express module for a few reasons:
// 1. We want to install our own first handler in the request handler chain
// so that we hook the send() function on the response object so that we
// can use it as a sink and see if we have tainted data in it.
// 2. We want to use that same first handler in the chain that we install to
// define the "body" property on the request object so that when it is
// actually set by another handler down the chain we can hook the assignment
// to this property which contains parsed user parameters from the POST HTTP
// request. Using this mechanism we can mark these parameters as tainted
// sources.
const iastLogger = require('../Logger/IastLogger')
const StackInfo = require('../StackInfo')
const Entity = require('../Entity')
const TaintTracker = require('../TaintTracker')
const SessionTracker = require('../SessionTracker')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const { match } = require('path-to-regexp');
const RequestInfo = require('../RequestInfo')
const Vulnerability = require('../Vulnerability')
const RequestRule = require('../RequestRules/RequestRule')
const HookParser = require("./HookParser");
const {ConfigInfo} = require('../ConfigFile/ConfigInfo')
const IastProperties = require('./IastProperties')
const DastHeaderParser = require("../IastDast/DastHeaderParser");
const DastResponseGenerator = require("../IastDast/DastResponseDataGenerator");
const AppInfo = require("../AppInfo");
const {keys} = require("../AdditionalInfo");
const TagsUtils = require("../TagsUtils");
const SourceUtils = require("../Rules/Utils/SourceUtils")
const AccumulatedTags = require("../Rules/BeforeRules/AccumulatedTags")
const ApiEndPoints = require("../ApiEndpoints")
const { Writable } = require('stream')
const Globals = require('../Globals')
const K8sSinkTrigger = require("../Rules/Utils/K8sSinkUtils")
let processedComponents = false;
let responseWriteDelayed = false;
let responseFinalized = false;
function mockWrite(chunk, encoding, callback) {
const stream = new Writable({
write(chunk, encoding, callback) {
callback();
}
})
return stream.write.apply(stream, arguments)
}
/*
Setting hook on response functions for whom we cannot buffer the writing to response in sink hook.
*/
function markResponseFunctionsAsFinalizers(res) {
res.send = markRequestAsFinalized(res.send)
res.sendFile = markRequestAsFinalized(res.sendFile)
res.json = markRequestAsFinalized(res.json)
res.redirect = markRequestAsFinalized(res.redirect)
res.render = markRequestAsFinalized(res.render)
}
function markRequestAsFinalized(func) {
return function () {
responseFinalized = true
return func.apply(this, arguments)
}
}
function runSink(responseObj, args, func, vulnerability) {
if (vulnerability === Vulnerability.XSS && !TaintTracker.isXssContentType(responseObj.getHeaders()['content-type'])) {
return
}
if (TaintTracker.isObjectTainted(args[0])) {
const parameters = StackInfo.getParamsStringArrayPostHook(responseObj, responseObj.constructor.name,
func.name, args, '')
if (Globals.IastK8sMode && vulnerability === Vulnerability.PASSWORD_LEAKAGE_SENT_DATA) {
K8sSinkTrigger.sinkTrigger(args[0], [vulnerability], parameters, "response")
}
else {
TaintTracker.sinkTrigger(args[0], vulnerability, parameters)
}
const responseTags = TagsUtils.getSenderTagsFromAdditionalInfoForItem(args[0])
AccumulatedTags.addResponseTags(responseTags)
}
}
/* On a direct call to res.write, we only call it at the end of the request on the entire
* response data because after calling res.write we cannot add response headers anymore.
* Instead, we write to a mock stream, only for getting the same return value/error as we would get from res.write.
*
* On an indirect call to res.write (e.g. triggered by res.send) we need to call the original write without a delay.
*/
function handleOrigWrite(responseObj, args, func) {
if (func.name === 'write' && !responseFinalized) {
responseWriteDelayed = true
return mockWrite(...args)
}
else {
return func.apply(responseObj, args)
}
}
function sinkHook (requestInfo, func, vulnerability, bufferingFunc, responseBody) {
return function () {
if (!HookParser.hooksActive) {
return func.apply(this, arguments)
}
// the original res.send() function expects a primitive
// string and not a Sting object so we must convert back
// before calling this function
// TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer. Received an instance of String
const arg = arguments[0]
const origArg = arguments[0] instanceof String ? arg.toString() : arg
const origArgs = [origArg, ...Array.origFrom(arguments).origSlice(1)]
try {
// sink before original function. Functions like res.send trigger the end request where we add our headers.
// So we want to add the sink before it's called.
runSink(this, arguments, func, vulnerability)
}
catch(e) {
iastLogger.eventLog.error(e)
}
const origReturn = handleOrigWrite(this, origArgs, func)
try {
if (bufferingFunc != null) {
bufferingFunc(origArgs[0], responseBody)
}
} catch (e) {
iastLogger.eventLog.error(e)
}
return origReturn
}
}
function writeResponseToBuffer (arg, responseBody) {
if (arg != null && arg !== "") {
arg = Buffer.isBuffer(arg) ? arg : Buffer.origFrom(arg)
responseBody.push(arg)
}
}
function onBeginRequest (requestInfo) {
if (processedComponents)
{
iastLogger.eventLog.debug(`Components: ${AppInfo.getComponentsJsonString()}`)
processedComponents = true
}
iastLogger.findingsLog.debug(`[checking URL: ${requestInfo.method} ${requestInfo.uri} queryString: ${requestInfo.queryString}]`)
AccumulatedTags.resetResponseTags()
}
function onEndRequest (func, req, res, requestInfo, responseBody) {
return function () {
if (HookParser.hooksActive) {
if (arguments.length > 0) {
writeResponseToBuffer(arguments[0], responseBody)
}
endRequest(req, res, requestInfo, responseBody)
}
return func.apply(this, arguments)
}
}
function endRequest (req, res, requestInfo, responseBody) {
try {
const responseData = Buffer.origConcat(responseBody) // Buffer.concat is required to pass compressed data (e.g gzip) to res.write correctly
const responseText = responseData.toString()
iastLogger.findingsLog.trace(responseText)
// session is created by middleware after onBeginRequest so we get it here
requestInfo.session = req.session != null ? Object.assign({}, req.session) : null
if (Math.floor(res.statusCode / 100) !== 4) {
RequestRule.reportVulnerabilities(requestInfo, responseText)
}
TaintTracker.ReportOnServerHeadersVulnerability(res.getHeaderNames(), requestInfo)
SessionTracker.ProcessResponse(req, res, requestInfo, responseText)
if (responseWriteDelayed && !responseFinalized)
{
res.origWrite(responseData)
}
} catch (error) {
iastLogger.eventLog.error(error)
}
}
function sourceHook (func, requestInfo, entityType) {
return function () {
if (HookParser.hooksActive) {
const stringResult = func.apply(this, arguments)
if (stringResult === undefined) return undefined
const arg = arguments[0]
return SourceUtils.getTaintedCopyOfProperty(requestInfo, 'Express.Request', func.name, arguments, arg, stringResult, entityType, new global.origError())
}else{
return func.apply(this, arguments)
}
}
}
function cookieHook (func, requestInfo) {
return function () {
if (HookParser.hooksActive) {
const name = arguments[0]
const value = arguments[1]
const options = arguments[2]
const isHttpOnly = options && Object.prototype.hasOwnProperty.call(options, 'httpOnly') && options.httpOnly
const isSecure = options && Object.prototype.hasOwnProperty.call(options, 'secure') && options.secure
if (!isHttpOnly || !isSecure) {
const entity = new Entity.Entity(name, value, Entity.EntityType.COOKIE)
if (!isHttpOnly) {
TaintTracker.reportVulnerability(TaintTracker.Vulnerability.HTTPONLY_COOKIE, this.toString(),
'response.cookie()', arguments, null, true, false, entity, requestInfo)
}
if (!isSecure) {
TaintTracker.reportVulnerability(TaintTracker.Vulnerability.SECURE_COOKIE, this.toString(),
'response.cookie()', arguments, null, true, false, entity, requestInfo)
}
}
}
return func.apply(this, arguments)
}
}
// TODO: is it possible here? we don't have request object here. can we get these in a different way
// // sink hooks -
// express.response.send = sinkHook(express.response.send, req, TaintTracker.Vulnerability.XSS) // ends a request
// express.response.write = sinkHook(express.response.write, req, TaintTracker.Vulnerability.XSS) // method of http.ServerResponse (not express)
// express.response.sendFile = sinkHook(express.response.sendFile, req, TaintTracker.Vulnerability.PATH_TRAVERSAL) // ends a request
// // all these ends a request
// express.response.end = onEndRequest(express.response.end, req) // method of http.ServerResponse (not express)
// express.response.sendStatus = onEndRequest(express.response.sendStatus, req) //can't be sen't with data - only end
// // Json is not xss. Only terminate the request.
// express.response.json = onEndRequest(express.response.json, req)
// express.response.jsonp = onEndRequest(express.response.jsonp, req)
// express.response.get = sourceHook(express.response.get, req, entity.EntityType.HEADER) // not sure it actually exists.. https://expressjs.com/en/4x/api.html#req.get
const expressHooksHandler = {
apply (target, thisArg, args) {
const app = Reflect.apply(target, thisArg, args)
return handleProxy(app)
},
construct (target, args) {
const app = Reflect.construct(target, args)
return handleProxy(app)
}
}
function handleProxy (app) {
// define a middleware
app.use(function (req, res, next) {
ApiEndPoints.reportDetectedApis(app, req)
if (!HookParser.hooksActive || req.__iastHeaders != null) { // ensure this middleware runs only once.
next()
return
}
responseWriteDelayed = false
responseFinalized = false
let dastRequestData = null;
let dastHeader = req.headers[DastHeaderParser.APPSCAN_HEADER_NAME_REQUEST];
if (dastHeader != null && ConfigInfo.ConfigInfo.enableDastCommunication)
{
iastLogger.eventLog.trace(`${DastHeaderParser.APPSCAN_HEADER_NAME_REQUEST}: ${dastHeader}`)
dastRequestData = DastHeaderParser.DastHeaderParser.parse(dastHeader)
}
const serverFlowTagsHeaderName = keys.IAST_TAG.toLowerCase()
const serverFlowTags = req.headers[serverFlowTagsHeaderName]
let updatedServerFlowTags = null
if (serverFlowTags != null) {
updatedServerFlowTags = TagsUtils.getIncrementedHeaderTags(serverFlowTags)
delete req.headers[serverFlowTagsHeaderName]
}
const requestInfo = new RequestInfo(req, dastRequestData, updatedServerFlowTags)
SourceUtils.setRequestInfo(requestInfo)
onBeginRequest(requestInfo)
const responseBody = []
// sink hooks
// res.render = sinkHook(res.render, req, Vulnerability.XSS) - internally calls res.write()
const responseWriteVulnerability = isFirstService(requestInfo) ? Vulnerability.XSS : Vulnerability.PASSWORD_LEAKAGE_SENT_DATA
res.send = sinkHook(requestInfo, res.send, responseWriteVulnerability) // ends a request
res.origWrite = res.write
res.write = sinkHook(requestInfo, res.write, responseWriteVulnerability, writeResponseToBuffer, responseBody) // method of http.ServerResponse (not express)
res.sendFile = sinkHook(requestInfo, res.sendFile, Vulnerability.PATH_TRAVERSAL) // ends a request
// res.redirect = sinkHook(res.redirect, Vulnerability.XSS)
// all these ends a request
res.end = onEndRequest(res.end, req, res, requestInfo, responseBody) // method of http.ServerResponse (not express)
// res.sendStatus = onEndRequest(res.sendStatus, req, res) // no need, calls response.end
// Json is not xss. Only terminate the request.
// res.json = onEndRequest(res.json, req, res) - no need, internally calls response.send
// res.jsonp = onEndRequest(res.jsonp, req, res) - no need, internally calls response.send
req.get = sourceHook(req.get, requestInfo, Entity.EntityType.HEADER) // not sure it actually exists.. https://expressjs.com/en/4x/api.html#req.get
// exploits in response:
res.cookie = cookieHook(res.cookie, requestInfo)
res.writeHead = onSendingHeaders(res.writeHead, requestInfo, req, res)
markResponseFunctionsAsFinalizers(res)
// define a setter and a getter to the body property
// a few caveats:
// 1. deleting this setter/getter property will remove our
// interception of this property.
// 2. the memory of this shadow property will not be deleted
// until the request object is deleted
tryDefiningPropertyOnRequest(req, requestInfo, 'body', Entity.EntityType.BODY)
tryDefiningPropertyOnRequest(req, requestInfo, 'query', Entity.EntityType.PARAMETER)
tryDefiningPropertyOnRequest(req, requestInfo, 'headers', Entity.EntityType.HEADER)
tryDefiningPropertyOnRequest(req, requestInfo, 'params', Entity.EntityType.PARAMETER)
tryDefiningPropertyOnRequest(req, requestInfo, 'cookies', Entity.EntityType.COOKIE)
tryDefiningPropertyOnRequest(req, requestInfo, 'signedCookies', Entity.EntityType.COOKIE)
next() // pass control to the next middleware function
})
/* we add the following parsers to ensure that body parameters and cookies are populated in the request,
* such that later on (e.g. when checking if a request is a login one), we could get them, even when user doesn't
* use these middlewares by himself.
*/
app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieParser('secrets'))
return app
}
function tryDefiningPropertyOnRequest(req, requestInfo, property, entityType) {
const newProperty = '__iast' + property[0].origToUpperCase() + property.origSubstring(1)
// if the property is on req already, we are setting it on the new property, therefore if the getter is called first, we will still return the actual value, not undefined
// even if the setter on those properties are called this value will be overwritten when setter is called anyway in the set proxy
if(req[newProperty] == null && req[property] != null) {
req[newProperty] = req[property]
if (property === 'body') {
requestInfo.updateBodyParameters(req[property])
}
}
try {
const set = function (value) {
req[newProperty] = value
if (property === 'body') {
requestInfo.updateBodyParameters(value)
}
// this code prevents the scenario where the cookie-parser middleware
// reads all the cookies and we add them all to requestInfo.usedCookies
else if (property === 'cookies' || property === 'signedCookies'){
requestInfo.clearUsedCookies()
}
}
const get = function () {
try {
requestInfo.routePath = getRoutePath(req)
}
catch (err) {
requestInfo.routePath = undefined
}
return SourceUtils.getProxyForSourceObject(requestInfo, req[newProperty], 'request', property, entityType)
}
IastProperties.definePropertyWithGetterSetter(req, property, get, set)
}
catch (err) {
iastLogger.eventLog.warning(err)
}
}
function getRoutePath(req) {
if (req.route != null && req.route.path != null && req.path != null) {
const templatePath = req.route.path
if (Array.isArray(templatePath)) {
return templatePath.find(path => {
const matcher = match(path, { decode: decodeURIComponent })
return matcher(req.path)
})
}
else {
return templatePath
}
}
return undefined
}
function onSendingHeaders(origFunc, requestInfo, req, res) {
return function () {
if (shouldAddAnyIastResponseHeaders(requestInfo)) {
addResponseIastHeader(requestInfo, req, res);
}
return origFunc.apply(this, arguments)
}
}
function addResponseIastHeader(requestInfo, req, res) {
if (requestInfo.dastRequestData != null) {
if (req.route) {
requestInfo.setRouteTemplateInDastResponse(req.route.path)
}
let headerValues = DastResponseGenerator.generateJsonString(requestInfo);
for (let i = 0; i < headerValues.length; i++) {
res.setHeader(DastHeaderParser.APPSCAN_HEADER_NAME_RESPONSE + i, headerValues[i]);
}
}
if (requestInfo.serverFlowTags != null) {
const setOfTagsToSend = AccumulatedTags.getResponseTagsToSend()
if (setOfTagsToSend.size !== 0) {
res.setHeader(keys.IAST_TAG.valueOf(), Array.from(setOfTagsToSend).join(", "))
}
}
}
function shouldAddAnyIastResponseHeaders(requestInfo) {
return requestInfo.dastRequestData != null || !isFirstService(requestInfo)
}
function isFirstService(requestInfo){
return requestInfo.serverFlowTags == null
}
module.exports.expressHooksHandler = expressHooksHandler