UNPKG

@hclsoftware/secagent

Version:

IAST agent

361 lines (328 loc) 16.2 kB
//IASTIGNORE /* * **************************************************** * Licensed Materials - Property of HCL. * (c) Copyright HCL Technologies Ltd. 2017, 2025. * Note to U.S. Government Users *Restricted Rights. * **************************************************** */ 'use strict' 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) }