@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
JavaScript
"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;