dtp-tripwire-agent
Version:
A service for protecting your node against malicious attacks and scans.
330 lines (295 loc) • 9.04 kB
text/typescript
// 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();