@hclsoftware/secagent
Version:
IAST agent
361 lines (328 loc) • 16.2 kB
JavaScript
//IASTIGNORE
/*
* ****************************************************
* Licensed Materials - Property of HCL.
* (c) Copyright HCL Technologies Ltd. 2017, 2025.
* Note to U.S. Government Users *Restricted Rights.
* ****************************************************
*/
const acorn = require('acorn')
const escodegen = require('escodegen')
const estraverse = require('estraverse')
const Module = require('module')
const EvalNode = require('./EvalNode')
const Utils = require("./Utils/Utils");
// Hook the function that compiles the JS code each time a new module is
// required (imported) so that we replace all the operator usages with
// a call to our operator function
const originalCompile = Module.prototype._compile
const nohook = function (content, filename, done) {
return done(content)
}
let currentHook = nohook
Module.prototype._compile = function (content, filename) {
const self = this
currentHook(content, filename, (newContent) => {
newContent = newContent || content
try {
originalCompile.call(self, newContent, filename)
} catch ({ name: errorName, message }) {
console.origLog(`Warning [IAST Secagent] unable to compile file ${filename} : ${errorName} - ${message}. Skipping.`)
originalCompile.call(self, content, filename)
}
})
}
const placeHook = function (hook) {
currentHook = hook
}
module.exports.removeHook = function () {
currentHook = nohook
Module.prototype._compile = originalCompile
}
const whitespaceMarker = '// {IAST}'
const consoleIdentifier = {
type: 'Identifier',
name: 'console'
}
const getAst = function (content, filename) {
// code from github comment https://github.com/estools/escodegen/issues/277#issuecomment-363903537
const programLines = content.origSplit('\n')
const replacedProgramLines = programLines.map(line => {
// If the line is blank or nothing but whitespace, return the whitespace marker as a comment.
if (line.length === 0 || /^\s+$/.test(line)) {
return whitespaceMarker
}
return line
})
const replacedProgram = replacedProgramLines.join('\n')
const comments = []
const tokens = []
const acornOptions = Object.assign({onComment : comments, onToken:tokens}, Utils.acornOptions);
try {
let ast = acorn.parse(replacedProgram.toString(), acornOptions)
// filter out one line comments which are not starting at the beginning of line. Such comments might cause a bug:
// suppose we have the following code: return /* some comment */ 'hello'. After parsing it becomes:
// return /* some comment */ \n 'hello'.
// So after the 'return' keyword we have nothing in the same line and therefore undefined will be returned instead of 'hello'.
ast.comments = comments.filter(c => c.loc.start.line !== c.loc.end.line || c.loc.start.column === 0)
ast.tokens = tokens
ast = escodegen.attachComments(ast, ast.comments, ast.tokens)
return ast
} catch ({ name, message }) {
console.origLog(`Warning [IAST Secagent] unable to parse file ${filename}: ${name} : ${message}`)
return null
}
}
const generateModifiedCode = function (ast) {
ast.body = [...getIastGlobalsDefinition.body, ...ast.body]
// change the operators to function calls
ast = estraverse.replace(ast, {
enter: replaceOperator})
const escodegenOptions = {
comment: true,
// sourceCode: content,
format: {
// preserveBlankLines: true, sourceCode + preserveBlankLines doesn't work https://github.com/estools/escodegen/issues/277 i tried but it cathes an exception
semicolons: false,
indent: {
adjustMultilineComment: true
}
}
}
// replace AST back to code
// newContent = escodegen.generate(ast, escodegen_options)
const output = escodegen.generate(ast, escodegenOptions)
return output.origSplit(whitespaceMarker).join('')
}
/* skipping these files because they causes an error:
1. bcrypt.js - bcryptjs.compareSync doesn't finish running - raised from ToStringPrimitiveTest.
2. xregexp-all.js - error occurred when running Ghost app - todo: find why.
3. webpack - lib for managing the build process of JavaScript applications. Injection of this lib causes errors on
4. uuid.js - using "??=" which fails the parser
client side. (e.g. cannot see the pages on browsers as usual). Tested on security team k8s demo app.
* */
const filesToSkip = ['bcrypt.js', 'xregexp-all.js', 'webpack']
placeHook((content, filename, done) => {
let newContent = content
if (!content.startsWith('//IASTIGNORE') && (!filesToSkip.some(element => filename.origStringIncludes(element)))) {
// there is an issue with comparing tainted objects to see if they are same tainted object
// remove shebang if it is there
content = content.origReplace(/^#!(.*\n)/, '')
try {
const ast = getAst(content, filename)
if (ast == null) {
newContent = content
} else {
newContent = generateModifiedCode(ast)
}
} catch (error) {
console.origLog(`Warning [IAST Secagent] failed to parse: ${filename}`)
console.origLog(error)
newContent = content
}
} else {
//console.origLog('Injector skipping ' + filename) // may be useful to know
}
done(newContent)
})
// We must also replace the operators in new dynamically created Function
// objects and not just for required (imported) modules as a lot of express
// and its view frameworks are using new Function objects created dynamically
// in order to create the HTTP responses from static templates
const origFunction = Function
Function = new Proxy(origFunction, {
construct(target, argArray, newTarget) {
const args = doFunctionHook(argArray)
return Reflect.construct(target, args, newTarget)
},
apply(target, thisArg, argArray) {
const args = doFunctionHook(argArray)
return Reflect.apply(target, thisArg, args)
}
})
function doFunctionHook(args) {
try {
// we must wrap the function code passed to the Function constructor
// with "let a = function() {" as the JS parser lib expects a full
// module code and not code that fills a function.
// When it encounters a return statement w/o knowing it is inside a
// function it just fails.
// So we wrap it with a function definition and then we peel it from
// the parsed AST once we have it.
const tempCode = 'let a = function() {' + args[args.length - 1] + '}'
const ast = getAst(tempCode, "")
if (ast == null) {
return args
} else {
ast.body = ast.body[0].declarations[0].init.body.body
// change the operators to function calls
args[args.length - 1] = generateModifiedCode(ast)
}
// console.origLog("new Function code: " + newCode)
} catch (error) {
console.origLog('Warning [IAST Secagent] failed to parse function')
console.origLog(args[args.length - 1])
console.origLog(error)
}
return args
}
// create an AST of calling our operator function
const iastGlobalsVar = 'iastGlobals'
const repPlusParsed = acorn.parse(`${iastGlobalsVar}.__iastPlus(1,2)`, Utils.acornOptions)
const repConcatParsed = acorn.parse(`${iastGlobalsVar}.__iastConcat(1,2)`, Utils.acornOptions)
const repEqualsEqualsParsed = acorn.parse(`${iastGlobalsVar}.__iastEqualsEquals(1,2)`, Utils.acornOptions)
const repNotEqualsParsed = acorn.parse(`${iastGlobalsVar}.__iastNotEquals(1,2)`, Utils.acornOptions)
const repEqualsEqualsEqualsParsed = acorn.parse(`${iastGlobalsVar}.__iastEqualsEqualsEquals(1,2)`, Utils.acornOptions)
const repNotEqualsEqualsParsed = acorn.parse(`${iastGlobalsVar}.__iastNotEqualsEquals(1,2)`, Utils.acornOptions)
const repIsObjectParsed = acorn.parse(`${iastGlobalsVar}.__iastIsObject(a)`, Utils.acornOptions)
const repIsNotObjectParsed = acorn.parse(`${iastGlobalsVar}.__iastIsNotObject(a)`, Utils.acornOptions)
const reptypeof = acorn.parse(`(typeof 1 === 'undefined' ? 'undefined' : ${iastGlobalsVar}.__iastTypeof(1))`, Utils.acornOptions)
const repNotParsed = acorn.parse(`${iastGlobalsVar}.__iastNot(1)`, Utils.acornOptions)
const repToString = acorn.parse(`${iastGlobalsVar}.__iastToString(1)`, Utils.acornOptions)
const readOnlyMethods = { createCipher: '__iastCryptoCreateCipher', exec: '__iastSqlite3DatabaseExec' }
const getIastGlobalsDefinition = acorn.parse(`let ${iastGlobalsVar} = console`, Utils.acornOptions)
// replace usage of operators with a call to our operator functions
const replaceOperator = (node) => {
// TODO: have to deal with operator += as well
if ((node.type === 'BinaryExpression') && (node.operator === '+')) {
// TODO: change only if it is string or do we want to track taint for
// all object types?
return replaceOperatorWithMethod(node, repPlusParsed)
} else if ((node.type === 'AssignmentExpression') && (node.operator === '+=')) {
node.right = replaceOperatorWithMethod(node, repPlusParsed)
node.operator = '='
} else if ((node.type === 'BinaryExpression') && (node.operator === '==') && (node.__iastDoNotReplace === undefined)) {
return replaceOperatorWithMethod(node, repEqualsEqualsParsed)
} else if ((node.type === 'BinaryExpression') && (node.operator === '!=') && (node.__iastDoNotReplace === undefined)) {
return replaceOperatorWithMethod(node, repNotEqualsParsed)
} else if ((node.type === 'BinaryExpression') && (node.operator === '===') && (node.__iastDoNotReplace === undefined)) {
return replaceTripleEqualityMethods(node, repIsObjectParsed, repEqualsEqualsEqualsParsed)
} else if ((node.type === 'BinaryExpression') && (node.operator === '!==') && (node.__iastDoNotReplace === undefined)) {
return replaceTripleEqualityMethods(node, repIsNotObjectParsed, repNotEqualsEqualsParsed)
} else if ((node.type === 'UnaryExpression') && (node.operator === 'typeof') && (node.__iastDoNotReplace === undefined)) {
const repCopy = JSON.origParse(JSON.origStringify(reptypeof))
repCopy.body[0].expression.test.__iastDoNotReplace = true
repCopy.body[0].expression.test.left.argument = node.argument
repCopy.body[0].expression.test.left.__iastDoNotReplace = true
repCopy.body[0].expression.alternate.arguments[0] = node.argument
return repCopy.body[0].expression
} else if (node.type === 'UnaryExpression' && node.operator === '!' && (node.__iastDoNotReplace === undefined)) {
const repCopy = JSON.origParse(JSON.origStringify(repNotParsed))
repCopy.body[0].expression.arguments[0] = node.argument
return repCopy.body[0].expression
} else if ((node.type === 'TaggedTemplateExpression') && (node.__iastDoNotReplace === undefined)) {
node.__iastDoNotReplace = true
node.quasi.__iastDoNotReplace = true
return node
} else if ((node.type === 'TemplateLiteral') && (node.__iastDoNotReplace === undefined)) {
const nodes = []
for (let i = 0; i < node.quasis.length; i++) {
if (node.quasis[i].value.raw !== '') {
const newNode = {
type: 'Literal',
value: node.quasis[i].value.cooked,
raw: "'" + node.quasis[i].value.cooked + "'"
}
nodes.push(newNode)
}
if (!node.quasis[i].tail) { nodes.push(node.expressions[i]) }
}
if (nodes.length > 0) { return transformArray(nodes) }
} else if (node.type === 'SwitchStatement' && node.__iastDoNotReplace === undefined) {
const repCopy = JSON.origParse(JSON.origStringify(repToString))
repCopy.body[0].expression.arguments[0] = node.discriminant
node.discriminant = repCopy.body[0].expression
node.__iastDoNotReplace = true
} else if (node.type === 'CallExpression' && node.callee && node.callee.property) {
// in CallExpression we want to replace calls to readOnlyMethods with our own global operators
// there are two cases that we want to replace - a normal function call and a call using apply()/call()
const callee = node.callee
if (Object.prototype.hasOwnProperty.call(readOnlyMethods, callee.property.name)){
const functionName = callee.property.name
// store that:
const that = callee.object
// add that as the first argument:
node.arguments.unshift(that)
// change that to 'console':
callee.object = consoleIdentifier
// change function name to our hook name:
callee.property.name = readOnlyMethods[functionName]
}else if (callee.object && callee.object.property && (callee.property.name === "call"|| callee.property.name === "apply")
&& readOnlyMethods.hasOwnProperty(callee.object.property.name) ){
// if we got here then we found a function with a name from readOnlyMethods, being called with call() or apply()
// we replace it to be our global hook called with apply/call
const functionName = callee.object.property.name
// function.call(that, arg1, arg2, ...) ==> global.our_hook_func.call(global, that, arg1, arg2, ...)
// function.apply(that, argsArray) ==> global.our_hook_func.apply(global, [that, ...argsArray])
if (callee.property.name === "apply" && node.arguments.length > 0){
node.arguments[1] = {type: 'ArrayExpression',
elements: [node.arguments[0],{type: 'SpreadElement', argument: node.arguments[1]}]}
node.arguments.shift()
}
// both apply and call accept that as the first argument and our that is now global.
node.arguments.unshift(consoleIdentifier)
// change that to 'console':
callee.object.object = consoleIdentifier
// change function name to our hook name:
callee.object.property.name = readOnlyMethods[functionName]
}
}
if (EvalNode.changeNodeIfEvalCall(node)) {
node = EvalNode.getNode()
}
return node
}
const replaceOperatorWithMethod = function (node, methodName) {
let repCopy = JSON.origParse(JSON.origStringify(methodName))
repCopy.body[0].expression.arguments[0] = node.left
repCopy.body[0].expression.arguments[1] = node.right
return repCopy
}
const replaceTripleEqualityMethods = function (node, isEqualMethodName, tripleEqualMethodname) {
let repCopy
if (isObjectPattern(node.left, node.right)) {
repCopy = JSON.origParse(JSON.origStringify(isEqualMethodName))
repCopy.body[0].expression.arguments[0] = node.left
} else if (isObjectPattern(node.right, node.left)) {
repCopy = JSON.origParse(JSON.origStringify(isEqualMethodName))
repCopy.body[0].expression.arguments[0] = node.right
} else {
return replaceOperatorWithMethod(node, tripleEqualMethodname)
}
return repCopy.body[0].expression
}
// identify the pattern a == Object(a)
// it used to identify if a variable is an object or primitive
// Object(Object) returns the object itself, so we give wrong return value on tainted strings
const isObjectPattern = function(left, right) {
return (right.type === 'CallExpression' && right.callee.type === 'Identifier' && right.callee.name === 'Object' &&
JSON.origStringify(left, ignoreStartEnd) === JSON.origStringify(right.arguments[0], ignoreStartEnd))
}
const ignoreStartEnd = function(k, v) {
if (['start','end', 'range', 'loc'].origArrayIncludes(k)) {
return undefined}
return v
}
const transformArray = function (nodes) {
if (nodes.length === 1) {
const left = nodes.pop()
const right = {
type: 'Literal',
value: '',
raw: "''"
}
const repCopy = JSON.origParse(JSON.origStringify(repConcatParsed))
repCopy.body[0].expression.arguments[0] = left
repCopy.body[0].expression.arguments[1] = right
nodes.push(repCopy.body[0].expression)
return nodes[0]
}
const right = nodes.pop()
const left = nodes.pop()
const repCopy = JSON.origParse(JSON.origStringify(repConcatParsed))
repCopy.body[0].expression.arguments[0] = left
repCopy.body[0].expression.arguments[1] = right
nodes.push(repCopy.body[0].expression)
return transformArray(nodes)
}