@hclsoftware/secagent
Version:
IAST agent
588 lines (521 loc) • 20.7 kB
JavaScript
//IASTIGNORE
/*
* ****************************************************
* Licensed Materials - Property of HCL.
* (c) Copyright HCL Technologies Ltd. 2017, 2025.
* Note to U.S. Government Users *Restricted Rights.
* ****************************************************
*/
const fs = require('fs')
const os = require('os')
const path = require('path')
const https = require('https')
const http = require('http')
const querystring = require('querystring')
const Readable = require('stream').Readable
const FormData = require('form-data')
const { URL } = require('url');
const Distributor = require('./Distributor')
const IastLogger = require('../Logger/IastLogger')
const HookParser = require("../Hooks/HookParser")
const ConfigFileManager = require("../ConfigFile/ConfigFileManager")
const {ConfigInfo} = require("../ConfigFile/ConfigInfo")
const {MemoryInfo} = require('../Utils/MemoryInfo')
// ASoC Defs
const ASOC_API_START = '/api/'
// constants for timing definitions
const SECOND = 1000
const MIN_INTERVAL_SEC = 1 // minimum polling interval in s
const DEFAULT_INTERVAL_SEC = 10 // default polling interval for enabled state in seconds
const FORCE_UPGRADE_TIMEOUT = 60 * 60 * 12 // seconds * minutes * hours
const REQUEST_TIMEOUT_MILLISEC = 30000 // 30 seconds
const CONFIG_HASH = 'ConfigHash'
let pollingInterval = DEFAULT_INTERVAL_SEC; // start as enabled
let pollingCounter = 0
const MAX_SKIPPED_CYCLES = 6; // maximum number of cycles we skip before polling asoc if there are no new vulnerabilities or if agent is disabled
let skippedCycles = MAX_SKIPPED_CYCLES; // initialize to max so that in the first cycle we enter the code
let updateTriggerTime
let intervalObj // timer object - https://javascript.info/settimeout-setinterval#setinterval
let sizeCounter = 0
let lastPollTimestamp // last successful polling time
let protocol = 'https'
let timerActive = false
let requestInProgress = false // avoid getting into the task when previous execution is not over
let activeExecutions = true
let memorySafe = true
const ASOC_DEFAULT_HOST = 'cloud.appscan.com'
const ASOC_SSL_HOSTS = [
"cloud.appscan.com", // PRODUCTION
"eu.cloud.appscan.com" // PRODUCTION-EU
]
let asocConfig = {
host: undefined,
accessToken: undefined, // will not work if no access token defined
port: undefined,
endpointStart: '/IAST/api/',
rejectUnauthorized: true
}
const AsocAccessTokenEnvVarName = "IAST_ACCESS_TOKEN"
const AsocHostEnvVarName = "IAST_HOST"
const ASOC_CONFIG_NAME = 'asoc-config.json'
const MEMORY_OK = "Memory usage below threshold. Enabling hooks.";
module.exports.ConfigureAsocConnector = () => {
// host and token priority:
// 1. env var
// 2. agent-config.json
// 3. asocConfig.json (legacy)
const agentConfig = loadAgentConfig()
IastLogger.eventLog.debug(`agent-config.json: ${JSON.origStringify(agentConfig)}`)
// first, try to get values from environment variable
if (process.env[AsocAccessTokenEnvVarName] != null) {
IastLogger.eventLog.info(`Setting access token from environment variable ${AsocAccessTokenEnvVarName}. Tokens from agent-config.js file and ${ASOC_CONFIG_NAME} file are ignored.`)
setAccessToken(process.env[AsocAccessTokenEnvVarName])
}
else if (agentConfig.accessToken != null && agentConfig.accessToken !== 'KeyPlaceholder') {
IastLogger.eventLog.info(`Setting access token from agent-config.js file. Token from ${ASOC_CONFIG_NAME} file is ignored.`)
setAccessToken(agentConfig.accessToken)
}
if (process.env[AsocHostEnvVarName] != null)
{
IastLogger.eventLog.info(`Setting host from environment variable ${AsocHostEnvVarName}. Tokens from agent-config.js file and ${ASOC_CONFIG_NAME} file are ignored.`)
setAsocHost(process.env[AsocHostEnvVarName])
}
else if (agentConfig.host != null && agentConfig.host !== 'HostPlaceholder') {
IastLogger.eventLog.info(`Setting host from agent-config.js file. Token from ${ASOC_CONFIG_NAME} file is ignored.`)
setAsocHost(agentConfig.host)
}
// if not all values are found in env, get values from asoc-config.json
if (asocConfig.host == null || asocConfig.accessToken == null) {
setAsocConfigFromFile()
}
// if host value not found in env var nor in a file, it has default value
if (asocConfig.host == null) {
IastLogger.eventLog.info(`Setting host to default - ${ASOC_DEFAULT_HOST}`)
asocConfig.host = ASOC_DEFAULT_HOST // default value
}
}
function loadAgentConfig() {
const configPath = path.resolve(__dirname, '../../agent-config.json');
if (fs.existsSync(configPath)) {
try {
const jsonData = fs.readFileSync(configPath, 'utf8');
return JSON.origParse(jsonData);
} catch (e) {
IastLogger.eventLog.error("Error parsing agent-config.json: " + e);
return {};
}
} else {
IastLogger.eventLog.info("agent-config.json file does not exist");
return {};
}
}
const setAsocConfigFromFile = () => {
let tempConfig = getAsocConfigFromFile()
if (asocConfig.host == null && tempConfig != null && tempConfig.host != null) {
IastLogger.eventLog.info(`Setting host from ${ASOC_CONFIG_NAME} file`)
setAsocHost(tempConfig.host)
}
if (asocConfig.accessToken == null && tempConfig != null && tempConfig.accessToken != null) {
IastLogger.eventLog.info(`Setting access token from ${ASOC_CONFIG_NAME} file`)
setAccessToken(tempConfig.accessToken)
}
}
const setAccessToken = (accessToken) => {
IastLogger.eventLog.info(`Setting access token to ${accessToken}`)
asocConfig.accessToken = accessToken
}
const setAsocHost = (origHost) => {
const url = new URL(origHost)
asocConfig.host = url.hostname
asocConfig.port = url.port
asocConfig.endpointStart = url.pathname.replace(/\/?$/, '') + ASOC_API_START
IastLogger.eventLog.info(`Setting host to ${asocConfig.host}`)
if (ASOC_SSL_HOSTS.includes(asocConfig.host)) {
asocConfig.rejectUnauthorized = true
IastLogger.eventLog.info(`Creating secured client for host ${asocConfig.host}`);
} else { // allow for self signed certificate, for ASE, staging and mirror
asocConfig.rejectUnauthorized = false
IastLogger.eventLog.info(`Creating trusted client for host ${asocConfig.host}`);
}
}
const getAsocConfigFromFile = () => {
const jsonPath = path.join(os.tmpdir(), ASOC_CONFIG_NAME)
try {
const rawdata = fs.readFileSync(jsonPath)
IastLogger.eventLog.info('config file uploaded:')
IastLogger.eventLog.debug(`asocConfig: ${rawdata}`)
return JSON.origParse(rawdata)
} catch (err) {
IastLogger.eventLog.info(`${jsonPath} does not exist.`)
}
}
const DistributorStatus = {
SUCCESS: 'success',
FAIL: 'fail',
DATA_PENDING: 'data_pending'
}
// Task to handle reporting to ASoC
// it first queries ASoC for execution state since last time it was called
// then it gets report from the distributor for the past interval and sends it back to ASoC
async function AsocTimerTask() {
try {
if (requestInProgress) return
pollingCounter = pollingCounter >= pollingInterval ? 1 : pollingCounter + 1 // increment counter
const timerActiveSampled = timerActive
let persistentMemoryWarning = ! memorySafe
let memoryWarning = MemoryInfo.checkMemoryWarning()
memorySafe = !memoryWarning
if (memoryWarning && !persistentMemoryWarning){
IastLogger.eventLog.error(MemoryInfo.getMemoryDebugInfo());
IastLogger.eventLog.error(`Memory Error!!! Process passed the memory threshold of ${ConfigInfo.ConfigInfo.memoryThreshold}. Disabling hooks.`)
HookParser.hooksActive = false
}
else if (!memoryWarning && persistentMemoryWarning){
IastLogger.eventLog.info(MEMORY_OK)
if (activeExecutions){
HookParser.hooksActive = true
}
}
if (pollingCounter === pollingInterval || !timerActiveSampled || (memoryWarning && !persistentMemoryWarning)) {
await doCommunicationCycle(memoryWarning)
}
if (!timerActiveSampled) {
IastLogger.eventLog.info('Stopping AppScan client')
clearInterval(intervalObj) // stop timer
}
} catch (err) {
IastLogger.eventLog.error(`TimerTask failed: ${err}`)
}
}
async function doCommunicationCycle(memoryWarning) {
// we want to reduce asoc communication to once every minute if there are no active executions or
// no pending issues
if (!Distributor.vulnerabilitiesPending() && skippedCycles < MAX_SKIPPED_CYCLES) {
skippedCycles ++ ;
return;
}
skippedCycles = 0;
requestInProgress = true
const currentTime = new Date()
sizeCounter = 0
let reportSucceeded = true
Distributor.takeSnapshot() // save current issues status
let currentExecutionsResult
try {
currentExecutionsResult = await getExecutions(currentTime)
} catch (err) {
IastLogger.eventLog.error(`getExecutions from ${asocConfig.host} failed: ${err}`)
}
const timeAfterGetExecutions = new Date()
let status
if (currentExecutionsResult !== undefined) {
const configFileHash = currentExecutionsResult[CONFIG_HASH]
if (currentExecutionsResult.UpdateAvailable && updateTriggerTime === undefined) { updateTriggerTime = plusMillis(currentTime, FORCE_UPGRADE_TIMEOUT * 1000) }
if (currentExecutionsResult.Executions.length === 0) {
if (activeExecutions) {
// deactivate hooks if no active execution
IastLogger.eventLog.info("No active executions. Disabling hooks.")
activeExecutions = false
HookParser.hooksActive = false
Distributor.clearAllIssues()
}
} else if (!activeExecutions && memorySafe && !HookParser.hooksActive) {
IastLogger.eventLog.info("An execution was activated. Enabling hooks.")
activeExecutions = true
HookParser.hooksActive = true
}
for (const execution of currentExecutionsResult.Executions) {
let executionStatus
try {
executionStatus = await reportToAsoc(execution.StartBeforeMilliSec, execution.EndBeforeMilliSec, execution.ExecutionId, currentTime, timeAfterGetExecutions)
} catch (err) {
IastLogger.eventLog.error(`report failed: ${err}`)
executionStatus = DistributorStatus.FAIL
}
if (executionStatus !== DistributorStatus.FAIL) {
// if report succeeded
if (execution.EndBeforeMilliSec != null && execution.EndBeforeMilliSec > 0 && executionStatus !== DistributorStatus.DATA_PENDING) { // this execution is done, no need to keep the map
Distributor.clearReported(execution.ExecutionId)
} else {
Distributor.stagedToReported(execution.ExecutionId)
}
if (executionStatus === DistributorStatus.DATA_PENDING) {
status = DistributorStatus.DATA_PENDING
}
} else { // report to asoc failed
reportSucceeded = false
}
}
await readConfigFile(configFileHash)
}
// if one of the reports did not succeed, we return the map. the map may contain issues from mixed
// executions so it is done per the whole polling period.
// but the set of the 'already reported to asoc' is per execution, so we will not re-send the issues,
// we will just iterate them again
if (currentExecutionsResult === undefined || !reportSucceeded) {
Distributor.returnToMap()
} else {
// update timestamp only if there is not pending data, so that we get same execution again
if (status !== DistributorStatus.DATA_PENDING) {
lastPollTimestamp = currentTime // if all succeeded, update timestamp of last successful poll
}
IastLogger.eventLog.debug(`Polling took ${millisecSince(currentTime, new Date())}ms. ${sizeCounter} chars sent.`)
}
if (reportSucceeded && memoryWarning){
// after we send a report we may clear enough memory to turn on the hooks again
if (!MemoryInfo.checkMemoryWarning())
{
memorySafe = true;
IastLogger.eventLog.info(MEMORY_OK);
if (activeExecutions)
{
HookParser.hooksActive = true
}
}
}
requestInProgress = false
}
// called when asocEnabled
module.exports.start = () => {
if (asocConfig.accessToken == null) return; // can't work if no access token
if (!timerActive) {
pollingCounter = 0
timerActive = true
intervalObj = setInterval(() => {
try {
AsocTimerTask()
} catch (err) {
IastLogger.eventLog.error(`starting TimerTask failed: ${err}`)
}
}, SECOND)
IastLogger.eventLog.info(`AppScan client started with interval of ${pollingInterval} seconds`)
lastPollTimestamp = new Date()
}
}
// // useful for cases where we stop and start immediately, e.g. set new polling interval
// function stopWithoutSending () {
// timerActive = false
// clearInterval(intervalObj) // stop timer
// }
module.exports.stopAfterNext = () => {
if (timerActive) {
IastLogger.eventLog.debug('Sending final report.')
timerActive = false
} else {
// notifyExecutionDone();
}
}
function getExecutions (currentTime) {
const lastPollMilliSec = millisecSince(lastPollTimestamp, currentTime)
const queryParams = {
lastPollMilliSec: Math.floor(lastPollMilliSec * 1.1),
AgentId: Distributor.getAgentId(),
AgentVersion: '0.0.1' // TODO: Distributor.getDistributor().getAgentVersion())
}
return sendGetRequest('CurrentExecution', queryParams)
}
function processGetResponse (res, resolve, reject, fullURL) {
const statusCode = res.statusCode
const statusMessage = res.statusMessage
// cumulate data
let body = ''
res.on('data', function (chunk) {
body += chunk
})
// resolve on end
res.on('end', function () {
if (statusCode !== 200) {
IastLogger.eventLog.error(`Request GET ${fullURL} failed: ${statusCode} ${statusMessage}, error: ${body}`)
reject(body) // error return of promise
}
try {
IastLogger.eventLog.debug(`Getting Execution info: GET ${fullURL} response: ${body}`)
body = JSON.origParse(body)
} catch (e) {
reject(e) // error return of promise
}
resolve(body) // good return of promise
})
}
// get data to report from the distributor and send to asoc
function reportToAsoc (startBeforeMilliSec, endBeforeMilliSec, execution, currentTime, timeAfterGetExecutions) {
return new Promise(function (resolve, reject) {
let status = DistributorStatus.SUCCESS
const startTime = minusMillis(currentTime, startBeforeMilliSec)
const endTime = minusMillis(timeAfterGetExecutions, endBeforeMilliSec)
const result = Distributor.getReport(startTime, endTime, execution)
if (result.pending) {
status = DistributorStatus.DATA_PENDING
}
if (result.data === undefined) {
resolve(status)
return
}
sizeCounter += result.data.length
// convert the data into stream
const dataStream = new Readable()
dataStream.push(result.data) // the string you want
dataStream.push(null) // indicates end-of-file basically - the end of the stream
// create form data
const form = new FormData()
form.append('fileToUpload', dataStream, {
filename: 'IAST-sample.json', // ... or:
contentType: 'application/json'
})
// prepare the multi part post request
const queryParams = {
executionId: execution
}
const multiPartHeaders = form.getHeaders()
multiPartHeaders.Authorization = 'Bearer ' + asocConfig.accessToken
multiPartHeaders.Accept = 'application/json'
const fullPath = asocConfig.endpointStart + 'UploadResults?' + querystring.stringify(queryParams)
const options = getRequestOptions(fullPath, 'POST', multiPartHeaders)
let req
const fullURL = getFullUrl(options.hostname, fullPath)
if (protocol === 'https') {
req = https.request(options, (res) => { sendPostRequest(res, resolve, reject, status, result.data, fullURL) }).setTimeout(REQUEST_TIMEOUT_MILLISEC)
} else {
req = http.request(options, (res) => { sendPostRequest(res, resolve, reject, status, result.data, fullURL) }).setTimeout(REQUEST_TIMEOUT_MILLISEC)
}
req.on('error', (err) => {
IastLogger.eventLog.error('Error sending data: ' + err) // usually caused by 'write EPIPE' error
reject(err)
})
req.on('timeout', () => {
reject('Request has timed out.')
})
try {
form.pipe(req)
} catch (err) {
IastLogger.eventLog.error('Error in pipe: ' + err)
reject(err)
}
})
}
function getRequestOptions(fullpath, httpMethod, httpHeaders) {
const options = {
hostname: asocConfig.host,
path: fullpath,
method: httpMethod,
headers: httpHeaders,
rejectUnauthorized: asocConfig.rejectUnauthorized,
}
if (asocConfig.port != null) {
options.port = asocConfig.port
}
return options
}
function getFullUrl(hostname, fullpath) {
return `${protocol}://${hostname}${fullpath}`
}
function sendPostRequest (res, resolve, reject, status, data, fullURL) {
const statusCode = res.statusCode
let responseMessage = ''
res.setEncoding('utf8')
res.on('data', (d) => {
responseMessage = d
})
res.on("error", function(error) {
IastLogger.eventLog.error(`POST ${fullURL} ${error} : ${responseMessage}`)
reject(error)
})
res.on('end', () => {
if (Math.floor(statusCode/100) !== 2) {
IastLogger.eventLog.error(`POST ${fullURL} ${statusCode} : ${responseMessage}`)
if ( Math.floor(statusCode/100) === 4){
// 400 status code means the issues were sent but rejected by ASoc
// no reason to try sending these issues again
IastLogger.findingsLog.error("Data rejected by AppScan server:\n" + data);
status = DistributorStatus.SUCCESS;
}
else {
status = DistributorStatus.FAIL
}
}
else {
IastLogger.eventLog.debug(`POST ${fullURL} ${statusCode} : ${responseMessage}`)
}
resolve(status)
})
}
// config updates
// wait some interval until pending data is sent
module.exports.setTestMode = (testMode) => {
const waitTime = timerActive ? (pollingInterval + 1) * SECOND : 0
setTimeout(function() {
protocol = testMode ? 'http' : 'https';
IastLogger.eventLog.debug(`setting protocol to ${protocol}`)
}, waitTime);
}
module.exports.setPollingInterval = (interval) => {
if (interval == null) { interval = DEFAULT_INTERVAL_SEC }
if (interval < MIN_INTERVAL_SEC) {
IastLogger.eventLog.warning(`Requested polling interval (${interval} sec) is smaller than minimum (${MIN_INTERVAL_SEC} sec). Not setting.`)
} else {
if (interval !== pollingInterval) {
IastLogger.eventLog.debug(`Setting polling interval to ${interval} sec`)
pollingCounter = 0
pollingInterval = interval
}
}
}
// time utils
// calc milliseconds between to timestamps
function millisecSince (from, to) {
return to.getTime() - from.getTime()
}
// return new timestamp with given time minus given millisec
function minusMillis (currentTime, millis) {
const result = new Date()
result.setTime(currentTime.getTime() - millis)
return result
}
// return new timestamp with given time plus given millisec
function plusMillis (currentTime, millis) {
const result = new Date()
result.setTime(currentTime.getTime() + millis)
return result
}
async function readConfigFile (fileHash) {
if (ConfigFileManager.isServerConfigExists() || fileHash == null || fileHash === ConfigFileManager.getCurrentAsocConfigHash()) {
return
}
let configResult
try {
configResult = await sendGetRequest('DownloadUserConfig')
}
catch (err) {
return
}
ConfigFileManager.notifyConfigFileUploadedFromAsoc(fileHash, configResult)
}
function sendGetRequest(endpoint, parameters) {
return new Promise(function (resolve, reject) {
let fullPath = asocConfig.endpointStart + endpoint
const query = querystring.stringify(parameters)
if (query !== "") {
fullPath += '?' + query
}
const headers = {Authorization: 'Bearer ' + asocConfig.accessToken}
const options = getRequestOptions(fullPath, 'GET', headers)
let req
const fullURL = getFullUrl(options.hostname, fullPath)
if (protocol === 'https') {
req = https.request(options, (res) => { processGetResponse(res, resolve, reject, fullURL) }).setTimeout(REQUEST_TIMEOUT_MILLISEC)
} else {
req = http.request(options, (res) => { processGetResponse(res, resolve, reject, fullURL) }).setTimeout(REQUEST_TIMEOUT_MILLISEC)
}
req.on('error', (e) => {
IastLogger.eventLog.error(`Get request to endpoint ${endpoint} failed: ${e}`)
skippedCycles = MAX_SKIPPED_CYCLES // try again next cycle
reject(e)
})
req.on('timeout', () => {
skippedCycles = MAX_SKIPPED_CYCLES // try again next cycle
reject('Request has timed out.')
})
req.end()
})
}