UNPKG

@aikidosec/firewall

Version:

Zen by Aikido is an embedded Web Application Firewall that autonomously protects Node.js apps against common and critical attacks

441 lines (440 loc) 18.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Agent = void 0; /* eslint-disable max-lines-per-function, no-console */ const os_1 = require("os"); const convertRequestBodyToString_1 = require("../helpers/convertRequestBodyToString"); const getAgentVersion_1 = require("../helpers/getAgentVersion"); const getNodeVersion_1 = require("../helpers/getNodeVersion"); const ipAddress_1 = require("../helpers/ipAddress"); const filterEmptyRequestHeaders_1 = require("../helpers/filterEmptyRequestHeaders"); const limitLengthMetadata_1 = require("../helpers/limitLengthMetadata"); const RateLimiter_1 = require("../ratelimiting/RateLimiter"); const fetchBlockedLists_1 = require("./api/fetchBlockedLists"); const pollForChanges_1 = require("./realtime/pollForChanges"); const Hostnames_1 = require("./Hostnames"); const InspectionStatistics_1 = require("./InspectionStatistics"); const Routes_1 = require("./Routes"); const ServiceConfig_1 = require("./ServiceConfig"); const Users_1 = require("./Users"); const wrapInstalledPackages_1 = require("./wrapInstalledPackages"); const isAikidoCI_1 = require("../helpers/isAikidoCI"); const AttackLogger_1 = require("./AttackLogger"); const Packages_1 = require("./Packages"); const AIStatistics_1 = require("./AIStatistics"); class Agent { constructor(block, logger, api, token, serverless) { this.block = block; this.logger = logger; this.api = api; this.token = token; this.serverless = serverless; this.started = false; this.sendHeartbeatEveryMS = 10 * 60 * 1000; this.checkIfHeartbeatIsNeededEveryMS = 60 * 1000; this.lastHeartbeat = performance.now(); this.reportedInitialStats = false; this.interval = undefined; this.preventedPrototypePollution = false; this.incompatiblePackages = {}; this.wrappedPackages = {}; this.packages = new Packages_1.Packages(); this.timeoutInMS = 30 * 1000; this.hostnames = new Hostnames_1.Hostnames(200); this.users = new Users_1.Users(1000); this.serviceConfig = new ServiceConfig_1.ServiceConfig([], Date.now(), [], [], true, [], []); this.routes = new Routes_1.Routes(200); this.rateLimiter = new RateLimiter_1.RateLimiter(5000, 120 * 60 * 1000); this.statistics = new InspectionStatistics_1.InspectionStatistics({ maxPerfSamplesInMemory: 5000, maxCompressedStatsInMemory: 20, // per operation }); this.aiStatistics = new AIStatistics_1.AIStatistics(); this.middlewareInstalled = false; this.attackLogger = new AttackLogger_1.AttackLogger(1000); if (typeof this.serverless === "string" && this.serverless.length === 0) { throw new Error("Serverless cannot be an empty string"); } } shouldBlock() { return this.block; } getHostnames() { return this.hostnames; } getInspectionStatistics() { return this.statistics; } getAIStatistics() { return this.aiStatistics; } unableToPreventPrototypePollution(incompatiblePackages) { this.incompatiblePackages = incompatiblePackages; const list = []; for (const pkg in incompatiblePackages) { list.push(`${pkg}@${incompatiblePackages[pkg]}`); } this.logger.log(`Unable to prevent prototype pollution, incompatible packages found: ${list.join(" ")}`); } onPrototypePollutionPrevented() { this.logger.log("Prevented prototype pollution!"); // Will be sent in the next heartbeat this.preventedPrototypePollution = true; this.incompatiblePackages = {}; } /** * Reports to the API that this agent has started */ async onStart() { if (this.token) { const result = await this.api.report(this.token, { type: "started", time: Date.now(), agent: this.getAgentInfo(), }, // We don't use `this.timeoutInMS` for startup event // Since Node.js is single threaded, the HTTP request is fired before other imports are required // It might take a long time before our code resumes 60 * 1000); this.checkForReportingAPIError(result); this.updateServiceConfig(result); await this.updateBlockedLists(); } } checkForReportingAPIError(result) { if (!result.success) { if (result.error === "invalid_token") { console.error("Aikido: We were unable to connect to the Aikido platform. Please verify that your token is correct."); } else { console.error(`Aikido: Failed to connect to the Aikido platform: ${result.error}`); } } } onErrorThrownByInterceptor({ error, module, method, }) { this.logger.log(`Internal error in module "${module}" in method "${method}"\n${error.stack}`); } /** * This function gets called when an attack is detected, it reports this attack to the API */ onDetectedAttack({ module, operation, kind, blocked, source, request, stack, paths, metadata, payload, }) { const attack = { type: "detected_attack", time: Date.now(), attack: { module: module, operation: operation, blocked: blocked, path: paths.length > 0 ? paths[0] : "", stack: stack, source: source, metadata: (0, limitLengthMetadata_1.limitLengthMetadata)(metadata, 4096), kind: kind, payload: JSON.stringify(payload).substring(0, 4096), user: request.user, }, request: { method: request.method, url: request.url, ipAddress: request.remoteAddress, userAgent: typeof request.headers["user-agent"] === "string" ? request.headers["user-agent"] : undefined, body: (0, convertRequestBodyToString_1.convertRequestBodyToString)(request.body), headers: (0, filterEmptyRequestHeaders_1.filterEmptyRequestHeaders)(request.headers), source: request.source, route: request.route, }, agent: this.getAgentInfo(), }; this.getInspectionStatistics().onDetectedAttack({ blocked, }); this.attackLogger.log(attack); if (this.token) { this.api.report(this.token, attack, this.timeoutInMS).catch(() => { this.logger.log("Failed to report attack"); }); } } /** * Sends a heartbeat via the API to the server (only when not in serverless mode) */ heartbeat(timeoutInMS = this.timeoutInMS) { this.sendHeartbeat(timeoutInMS).catch(() => { this.logger.log("Failed to do heartbeat"); }); } getUsers() { return this.users; } getConfig() { return this.serviceConfig; } updateServiceConfig(response) { if (response.success) { if (typeof response.block === "boolean") { if (response.block !== this.block) { this.block = response.block; this.logger.log(`Block mode has been set to ${this.block ? "on" : "off"}`); } } if (response.endpoints) { this.serviceConfig.updateConfig(response.endpoints && Array.isArray(response.endpoints) ? response.endpoints : [], typeof response.configUpdatedAt === "number" ? response.configUpdatedAt : Date.now(), response.blockedUserIds && Array.isArray(response.blockedUserIds) ? response.blockedUserIds : [], response.allowedIPAddresses && Array.isArray(response.allowedIPAddresses) ? response.allowedIPAddresses : [], typeof response.receivedAnyStats === "boolean" ? response.receivedAnyStats : true); } const minimumHeartbeatIntervalMS = 2 * 60 * 1000; if (typeof response.heartbeatIntervalInMS === "number" && response.heartbeatIntervalInMS >= minimumHeartbeatIntervalMS) { this.sendHeartbeatEveryMS = response.heartbeatIntervalInMS; } } } async sendHeartbeat(timeoutInMS) { if (this.token) { this.logger.log("Heartbeat..."); const stats = this.statistics.getStats(); const aiStats = this.aiStatistics.getStats(); const routes = this.routes.asArray(); const outgoingDomains = this.hostnames.asArray(); const users = this.users.asArray(); const packages = this.packages.asArray(); const endedAt = Date.now(); this.statistics.reset(); this.aiStatistics.reset(); this.routes.clear(); this.hostnames.clear(); this.users.clear(); this.packages.clear(); const response = await this.api.report(this.token, { type: "heartbeat", time: Date.now(), agent: this.getAgentInfo(), stats: { operations: stats.operations, startedAt: stats.startedAt, endedAt: endedAt, requests: stats.requests, userAgents: stats.userAgents, ipAddresses: stats.ipAddresses, sqlTokenizationFailures: stats.sqlTokenizationFailures, }, ai: aiStats, packages, hostnames: outgoingDomains, routes: routes, users: users, middlewareInstalled: this.middlewareInstalled, }, timeoutInMS); this.updateServiceConfig(response); } } /** * Starts a heartbeat when not in serverless mode : Make contact with api every x seconds. */ startHeartbeats() { if (this.serverless) { this.logger.log("Running in serverless environment, not starting heartbeats"); return; } if (!this.token) { this.logger.log("No token provided, not starting heartbeats"); return; } /* c8 ignore next 3 */ if (this.interval) { throw new Error("Interval already started"); } this.interval = setInterval(() => { const now = performance.now(); const diff = now - this.lastHeartbeat; const shouldSendHeartbeat = diff > this.sendHeartbeatEveryMS; const hasStats = !this.statistics.isEmpty() || !this.aiStatistics.isEmpty(); const canSendInitialStats = !this.serviceConfig.hasReceivedAnyStats() && hasStats; const shouldReportInitialStats = !this.reportedInitialStats && canSendInitialStats; if (shouldSendHeartbeat || shouldReportInitialStats) { this.heartbeat(); this.lastHeartbeat = now; this.reportedInitialStats = true; } }, this.checkIfHeartbeatIsNeededEveryMS); this.interval.unref(); } async updateBlockedLists() { if (!this.token) { return; } if (this.serverless) { // Not supported in serverless mode return; } try { const { blockedIPAddresses, blockedUserAgents, allowedIPAddresses, monitoredIPAddresses, monitoredUserAgents, userAgentDetails, } = await (0, fetchBlockedLists_1.fetchBlockedLists)(this.token); this.serviceConfig.updateBlockedIPAddresses(blockedIPAddresses); this.serviceConfig.updateBlockedUserAgents(blockedUserAgents); this.serviceConfig.updateAllowedIPAddresses(allowedIPAddresses); this.serviceConfig.updateMonitoredIPAddresses(monitoredIPAddresses); this.serviceConfig.updateMonitoredUserAgents(monitoredUserAgents); this.serviceConfig.updateUserAgentDetails(userAgentDetails); } catch (error) { console.error(`Aikido: Failed to update blocked lists: ${error.message}`); } } startPollingForConfigChanges() { (0, pollForChanges_1.pollForChanges)({ token: this.token, serverless: this.serverless, logger: this.logger, lastUpdatedAt: this.serviceConfig.getLastUpdatedAt(), onConfigUpdate: (config) => { this.updateServiceConfig({ success: true, ...config }); this.updateBlockedLists().catch((error) => { this.logger.log(`Failed to update blocked lists: ${error.message}`); }); }, }); } getAgentInfo() { return { dryMode: !this.block, /* c8 ignore next */ hostname: (0, os_1.hostname)() || "", version: (0, getAgentVersion_1.getAgentVersion)(), library: "firewall-node", /* c8 ignore next */ ipAddress: (0, ipAddress_1.ip)() || "", packages: Object.keys(this.wrappedPackages).reduce((packages, pkg) => { const details = this.wrappedPackages[pkg]; if (details.version && details.supported) { packages[pkg] = details.version; } return packages; }, {}), incompatiblePackages: { prototypePollution: this.incompatiblePackages, }, preventedPrototypePollution: this.preventedPrototypePollution, nodeEnv: process.env.NODE_ENV || "", serverless: !!this.serverless, stack: Object.keys(this.wrappedPackages).concat(this.serverless ? [this.serverless] : []), os: { name: (0, os_1.platform)(), version: (0, os_1.release)(), }, platform: { version: (0, getNodeVersion_1.getSemverNodeVersion)(), arch: process.arch, }, }; } start(wrappers) { if (this.started) { throw new Error("Agent already started!"); } this.started = true; this.logger.log(`Starting agent v${(0, getAgentVersion_1.getAgentVersion)()}...`); if (!this.block) { this.logger.log("Dry mode enabled, no requests will be blocked!"); } if (this.token) { this.logger.log("Found token, reporting enabled!"); } else { this.logger.log("No token provided, disabling reporting."); if (!this.block && !(0, isAikidoCI_1.isAikidoCI)()) { console.log("AIKIDO: Running in monitoring only mode without reporting to Aikido Cloud. Set AIKIDO_BLOCK=true to enable blocking."); } } // When our library is required, we are not intercepting `require` calls yet // We need to add our library to the list of packages manually this.onPackageRequired("@aikidosec/firewall", (0, getAgentVersion_1.getAgentVersion)()); (0, wrapInstalledPackages_1.wrapInstalledPackages)(wrappers, this.serverless); // Send startup event and wait for config // Then start heartbeats and polling for config changes this.onStart() .then(() => { this.startHeartbeats(); this.startPollingForConfigChanges(); }) .catch((err) => { console.error(`Aikido: Failed to start agent: ${err.message}`); }); } onFailedToWrapMethod(module, name, error) { this.logger.log(`Failed to wrap method ${name} in module ${module}: ${error.message}`); } onFailedToWrapModule(module, error) { this.logger.log(`Failed to wrap module ${module}: ${error.message}`); } onPackageRequired(name, version) { this.packages.addPackage({ name, version, }); } onPackageWrapped(name, details) { if (this.wrappedPackages[name]) { // Already reported as wrapped return; } this.wrappedPackages[name] = details; if (details.version) { if (details.supported) { this.logger.log(`${name}@${details.version} is supported!`); } else { this.logger.log(`${name}@${details.version} is not supported!`); } } } onConnectHostname(hostname, port) { this.hostnames.add(hostname, port); } onRouteExecute(context) { this.routes.addRoute(context); } hasGraphQLSchema(method, path) { return this.routes.hasGraphQLSchema(method, path); } onGraphQLSchema(method, path, schema) { this.routes.setGraphQLSchema(method, path, schema); } onGraphQLExecute(method, path, type, topLevelFields) { topLevelFields.forEach((field) => { this.routes.addGraphQLField(method, path, type, field); }); } onRouteRateLimited(match) { // The count will be incremented for the rate-limited route, not for the exact route // So if it's a wildcard route, the count will be incremented for the wildcard route this.routes.countRouteRateLimited(match); } getRoutes() { return this.routes; } log(message) { this.logger.log(message); } async flushStats(timeoutInMS) { this.statistics.forceCompress(); await this.sendHeartbeat(timeoutInMS); } getRateLimiter() { return this.rateLimiter; } onMiddlewareExecuted() { this.middlewareInstalled = true; } } exports.Agent = Agent;