UNPKG

dtp-tripwire-agent

Version:

A service for protecting your node against malicious attacks and scans.

330 lines (295 loc) 9.04 kB
// src/service.js // Copyright (C) 2025 DTP Technologies, LLC // All Rights Reserved import env from "./config/env.js"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import { NextFunction, Request, Response } from "express"; import { readJsonFile } from "./lib/tools.js"; import log from "./lib/log.js"; const UPDATE_INTERVAL = 1000 * 60 * 15; export type UriSet = Set<string>; export type MethodMap = Map<string, UriSet>; export interface TripwireConfigFileEntry { name: string; uris: string[]; } export interface TripwireConfigFile { prefixes: string[]; strings: string[]; methods: TripwireConfigFileEntry[]; } export interface TripwireConfig { prefixes: string[]; strings: string[]; methods: MethodMap; } export enum TripwireIncidentType { MALFORMED_REQUEST = "malformed-request", PREFIX_MATCH = "uri-prefix-match", SUBSTRING_MATCH = "uri-substring-match", PROHIBITED_METHOD = "prohibited-method", } export interface ITripwireIncident { type: TripwireIncidentType; matching?: string; } class TripwireService { cacheDirectory = path.join(env.cache.path, "tripwire"); configuration?: TripwireConfig; updateTimerId?: NodeJS.Timeout; async start(): Promise<void> { await this.prepareCacheDirectory(); log.info("starting tripwire service"); await this.loadConfiguration(); log.info("starting tripwire configuration update interval"); this.updateTimerId = setInterval( this.loadConfiguration.bind(this), UPDATE_INTERVAL ); } async stop(): Promise<void> { if (this.updateTimerId) { log.info("stopping tripwire configuration update interval"); clearInterval(this.updateTimerId); delete this.updateTimerId; } log.info("tripwire service stopped"); } middleware() { return async ( req: Request, _res: Response, next: NextFunction ): Promise<void> => { if (!this.configuration) { log.error("service configuration not loaded"); const error = new Error("Configuration not loaded"); error.name = "TripwireConfigurationError"; error.statusCode = 503; return next(error); } const { method, url } = req; if (method[0] === "\\" || url[0] === "\\") { const error = new Error("request is blocked"); error.name = "TripwireRequestBlocked"; error.statusCode = 403; log.warn(error.message, { method, url }); this.reportIncident(req, { type: TripwireIncidentType.MALFORMED_REQUEST, }); return next(error); } for (const prefix of this.configuration.prefixes) { if (url.startsWith(prefix)) { const error = new Error("request is blocked"); error.statusCode = 403; log.warn("blocked request", { method, url }); this.reportIncident(req, { type: TripwireIncidentType.PREFIX_MATCH, matching: prefix, }); return next(error); } } for (const text of this.configuration.strings) { if (url.includes(text)) { const error = new Error("request is blocked"); error.statusCode = 403; log.warn("blocked request", { method, url }); this.reportIncident(req, { type: TripwireIncidentType.SUBSTRING_MATCH, matching: text, }); return next(error); } } const blockedMethod = this.configuration.methods.get(method); if (!blockedMethod) { return; } if (blockedMethod.has(url)) { const error = new Error(`request is blocked`); error.statusCode = 403; log.warn("blocked request", { method, url }); this.reportIncident(req, { type: TripwireIncidentType.PREFIX_MATCH, }); return next(error); } next(); }; } async processError(error: Error, req: Request, res: Response): Promise<void> { let interested = req.url.match(/[admin|user]/gi); if (!interested) { return; } return this.requestReview(error, req, res); } async loadConfiguration(): Promise<void> { let configFile: TripwireConfigFile | undefined; try { configFile = await this.fetchConfiguration(); } catch (error) { // fall through } try { if (!configFile) { configFile = await this.loadConfigurationFile(); } } catch (error) { // fall through } if (!configFile) { const error = new Error("failed to load Tripwire configuration"); error.statusCode = 503; throw error; } this.configuration = { prefixes: configFile.prefixes, strings: configFile.strings, methods: new Map<string, Set<string>>(), }; for (const entry of configFile.methods) { this.configuration.methods.set(entry.name, new Set<string>(entry.uris)); } } async fetchConfiguration(): Promise<TripwireConfigFile | undefined> { const url = path.join(env.api.url, "configuration"); log.info("loading tripwire configuration", { url }); const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${env.api.clientId}:${env.api.clientSecret}`, }, }); if (!response.ok) { log.error("failed to receive tripwire service configuration", { status: response.status, statusText: response.statusText, }); return; } const json = await response.json(); if (!json.success) { log.error("failed to receive tripwire service configuration", { message: json.message, }); return; } log.info("tripwire service configuration updated"); return json.configuration as TripwireConfigFile; } async loadConfigurationFile(): Promise<TripwireConfigFile | undefined> { const filePath = path.join(this.cacheDirectory, "config.json"); log.info("loading tripwire configuration from cache", { filePath }); try { const config = await readJsonFile<TripwireConfigFile>(filePath); return config; } catch (error) { log.error("failed to load JSON configuration file", { filePath, error }); return; } } async prepareCacheDirectory(): Promise<void> { await fs.promises.mkdir(this.cacheDirectory, { recursive: true }); } async requestReview( error: Error, req: Request, res: Response ): Promise<void> { const statusCode = error.statusCode || res.statusCode || 500; const statusMessage = error.message || res.statusMessage || "An error has occured"; log.error("requesting tripwire analysis and review", { req: { ip: req.ip, url: req.url, }, statusCode, statusMessage, error, }); try { const report = { hostname: os.hostname(), statusCode, statusMessage, error, request: { ip: req.ip, ips: req.ips, host: req.host, method: req.method, url: req.url, originalUrl: req.originalUrl, protocol: req.protocol, headers: req.headers, body: req.body, }, }; const body = JSON.stringify(report); const response = await fetch(env.n8n.errorUrl, { method: "POST", headers: { "X-DTP-Tripwire": `${env.n8n.authToken}`, "Content-Type": "application/json", "Content-Length": body.length.toString(), }, body, }); if (!response.ok) { const error = new Error("failed to request tripwire review"); error.statusCode = response.status; throw error; } log.info("tripwire analysis request submitted"); } catch (error) { log.error("failed to send tripwire event", { error }); } } async reportIncident( req: Request, incident: ITripwireIncident ): Promise<void> { try { const report = { hostname: os.hostname(), incident, request: { ip: req.ip, ips: req.ips, host: req.host, method: req.method, url: req.url, originalUrl: req.originalUrl, protocol: req.protocol, headers: req.headers, body: req.body, }, }; const body = JSON.stringify(report); const response = await fetch(env.n8n.eventUrl, { method: "POST", headers: { "X-DTP-Tripwire": `${env.n8n.authToken}`, "Content-Type": "application/json", "Content-Length": body.length.toString(), }, body, }); if (!response.ok) { const error = new Error("failed to report tripwire incident"); error.statusCode = response.status; throw error; } log.info("tripwire incident reported"); } catch (error) { log.error("failed to send tripwire event", { error }); } } } export default new TripwireService();