UNPKG

@aikidosec/firewall

Version:

Zen by Aikido is an embedded Application Firewall that autonomously protects Node.js apps against common and critical attacks, provides rate limiting, detects malicious traffic (including bots), and more.

487 lines (486 loc) 19.7 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"); const AttackWaveDetector_1 = require("../vulnerabilities/attack-wave-detection/AttackWaveDetector"); 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 = 30 * 1000; this.lastHeartbeat = performance.now(); this.sentHeartbeatCounter = 0; 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); this.attackWaveDetector = new AttackWaveDetector_1.AttackWaveDetector(); if (typeof this.serverless === "string" && this.serverless.length === 0) { throw new Error("Serverless cannot be an empty string"); } } shouldBlock() { return this.block; } isServerless() { // e.g. "lambda" or "gcp" return typeof this.serverless === "string" && this.serverless.length > 0; } 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"); }) .then(() => { this.sentHeartbeatCounter++; }); } 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 timeSinceLastHeartbeat = performance.now() - this.lastHeartbeat; if (timeSinceLastHeartbeat > this.getHeartbeatInterval()) { this.heartbeat(); this.lastHeartbeat = performance.now(); } }, 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; } getHeartbeatInterval() { switch (this.sentHeartbeatCounter) { case 0: // The first heartbeat should be sent after 30 seconds return 1000 * 30; case 1: // The second heartbeat should be sent after 2 minutes return 1000 * 60 * 2; default: // Subsequent heartbeats are sent every `sendHeartbeatEveryMS` return this.sendHeartbeatEveryMS; } } getAttackWaveDetector() { return this.attackWaveDetector; } /** * This function gets called when an attack wave is detected, it reports this attack wave to the API */ onDetectedAttackWave({ request, metadata, }) { const attack = { type: "detected_attack_wave", time: Date.now(), attack: { metadata: (0, limitLengthMetadata_1.limitLengthMetadata)(metadata, 4096), user: request.user, }, request: { ipAddress: request.remoteAddress, userAgent: typeof request.headers["user-agent"] === "string" ? request.headers["user-agent"] : undefined, source: request.source, }, agent: this.getAgentInfo(), }; if (this.token) { this.api.report(this.token, attack, this.timeoutInMS).catch(() => { this.logger.log("Failed to report attack wave"); }); } } } exports.Agent = Agent;