Привет! Меня зовут Рудаков Александр, я занимаюсь информационной безопасностью в компании "ЛАНИТ-Интеграция". Однажды, в рамках работы над проектом, мне понадобилось организовать небольшой непрерывный мониторинг (с хранением истории) открытых портов в подсети серверов. Требовалось за короткое время сделать рабочий прототип решения для данной задачи. В этой статье я расскажу о том, как с помощью nmap, Node.JS, PostgreSQL и ORM Sequelize организовать мониторинг открытых портов на хостах.
CRUD-приложение на Node.JS
В прошлой статье я рассказывал про решение для автоматизации процессов n8n. Кстати, после прочтения статьи основатель n8n Ян Оберхаузер полностью согласился с описанными минусами n8n. К сожалению, n8n не является универсальным инструментом, и поэтому захотелось попробовать реализовать серверную часть на Node.JS и познакомиться с JS на бэкенде. Для этого была выбрана задача непрерывного мониторинга открытых портов.
Исходные компоненты будущего решения понятны – сканер портов, база данных и самописное серверное приложение, которое реализует бизнес-логику. В качестве основного модуля выбираем сканер портов NMAP, для которого есть готовый пакет node-nmap, позволяющий получить результаты сканирования «на лету» в приложении в виде объектов.
При выборе БД для данного решения я решил остановиться на PostgreSQL вместо NoSQL по следующим причинам:
- open source SQL СУБД;
- логические требования к данным, включая их структуру, определены заранее;
- SQL обеспечивает целостность данных;
- наличие Object-relational mapping (ORM) для Node.JS.
ORM — это объектно-реляционное отображение, технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных». Обеспечивает работу с данными в терминах классов, а также преобразовывает данные классов в данные для хранения в СУБД.
Особых требований к производительности и отказоустойчивости у меня нет было, так что выбор был предопределен.
Архитектура решения
Решение должно выводить отчет по портам для конкретного IP (либо доменного имени) по запросу от бота Telegram. Дополнительно должна присутствовать возможность ручного запуска сканирования по IP-адресу сервера через API посредством curl или Postman. Очень хотелось обойтись без ручного написания SQL-запросов, поэтому я использовал ORM Sequelize.
Основные NPM-пакеты Node.JS, которые использовались в данном решении:
- Express в качестве веб-сервера;
- Sequelize для работы с БД;
- Node-nmap для получения результатов сканирования;
- Telegraf для взаимодействия с мессенджером.
Структура БД
Исходя из постановки задачи, нам нужно реализовать типичное CRUD-приложение, которое умеет вносить данные в БД и читать их, формируя простейший отчет. CRUD — акроним, обозначающий четыре базовые функции, используемые при работе с базами данных: создание (create), чтение (read), модификация (update), удаление (delete). Из данных функций в рамках решаемой задачи пока актуальны только создание и чтение для генерации простого отчета.
Я выделил основные сущности предметной области и их взаимосвязи между собой, получив следующую структуру БД.
Здесь представлены следующие таблицы БД:
scan – таблица «сканирование» содержит дату проведения сканирования;
port – таблица «порт» содержит номер открытого порта и описание сервиса на нем;
NetObject – таблица «сетевой объект» содержит IP-адрес/имя хоста, который сканируем;
ScanNetObjectPort – таблица, содержащая связь «многие ко многим». Простыми словами, эта таблица показывает, какой порт сетевого объекта в каком сканировании участвовал и какой сервис работал на данном порту. Данная таблица связывает между собой все остальные таблицы.
ORM Sequelize
Для простой работы с БД решено было использовать ORM Sequelize. Изучив документацию, подключаем к приложению БД PostgreSQL, которая установлена на том же сервере.
В терминологии Sequelize, модель – это абстракция, которая представляет таблицу в БД. Модель сообщает Sequelize информацию о сущности, которую она представляет, например, имя таблицы в базе данных и какие столбцы у нее есть. Объект – это экземпляр модели с конкретными характеристиками (например, сервер с адресом 127.0.0.1 или порт 22 с сервисом ssh).
Используя ORM, структура базы данных в моделях описывается очень просто:
// Table NetObject
const NetObject = sequelize.define(«netObject», {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
IP: {
type: DataTypes.STRING,
unique: true
}
});
// Table Port
const Port = sequelize.define(«port», {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
number: {
type: DataTypes.INTEGER
},
description: {
type: DataTypes.STRING,
}
}, {timestamps: false});
// Table Scan
const Scan = sequelize.define(«scan», {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
date: {
type: DataTypes.DATE
}
}, {timestamps: false});
const ScanNetObject = sequelize.define(«ScanNetObject»,{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
}, {timestamps: false});
ScanNetObjectPort.belongsTo(Scan);
ScanNetObjectPort.belongsTo(NetObject);
ScanNetObjectPort.belongsTo(Port);
Scan.hasMany(ScanNetObjectPort);
NetObject.hasMany(ScanNetObjectPort);
Port.hasMany(ScanNetObjectPort);
Приведенный выше код создаст нам следующую структуру БД:
Поля createdAt, updatedAt были автоматически созданы Sequelize. Опцией {timestamps: false} эти поля были отключены для моделей scan и port.
Обратим внимание, что в приведенном описании имена таблиц (ports, scans) никогда не определялись явно. Потому, что Sequelize автоматически именуют таблицы, добавляя к имени объекта букву s. За правильное именование в Sequelize для множественных чисел (person -> people) отвечает пакет inflection-js, который в качестве справочника использует Вики-словарь. Это небольшое отступление показывает нам уровень абстракции нашего приложения непосредственно от СУБД. За создание таблиц и написание SQL-запросов у нас отвечает Sequelize, и именно поэтому ORM очень любят использовать при реализации pet-проектов, подобных нашему.
NMAP
Для данного сканера есть готовый NPM-пакет node-nmap, которым мы и воспользуемся. Из документации, понятно, что результаты сканирования представляют собой массив объектов JSON, содержащий информацию о каждом хосте:
[
{
«hostname»:«localhost»,
«ip»:«127.0.0.1»,
«mac»:null,
«openPorts»:[
{«port»:22,«protocol»:«tcp»,«service»:«ssh»,«method»:«table»}
],
«osNmap»:null
}
]
Таким образом, в функции сканирования и сохранения данных в БД нам достаточно получить адрес объекта сканирования и обработать данный массив.
Сохранение результатов сканирования в БД
ORM Sequelize делает за нас большую часть работы по сохранению информации в БД. Напишем отдельную функцию сканирования, которая получает на вход IP-адрес (либо DNS-имя, это тоже валидное значение для nmap). При получении результата сканирования мы должны создать объект Scan, содержащий текущую дату и время сканирования. Далее переходим к сетевому объекту, который мы сканируем, он уже может быть в таблице NetObjects либо еще нет. Оценим удобство ORM: метод findOrCreate применительно к объекту возвращает нам либо новый сетевой объект, либо уже существующий. Затем пробегаем циклом по результатам сканирования, все открытые порты с висящими на них сервисами сохраняем через объект Port. Не забываем привязывать объекты Scan и Port первичными ключами к объекту ScanNetObjectPort.
Итоговая функция сканирования и сохранения информации в БД:
exports.scan_art = (IPaddr) => {
const nmap_scan = new nmap.NmapScan(IPaddr.toString());
console.log(«Starting nmap scan...»);
nmap_scan.on('complete', function(data){
let sca = {«date»: Date.now()};
// Циклом проходим результаты скана (там может быть более 1 адреса), создаем Scan, NetObject (если его нет), ScanNetObjectPort
Scan.create(sca)
.then ((newScan) => {
NetObject.findOrCreate({where:{IP: IPaddr}, defaults: {IP: IPaddr}})
.then((newNetObject) => {
for (let var_host in data){
for (let var_port in data[var_host].openPorts){
let str_port = data[var_host].openPorts[var_port].port.toString();
let str_desc = data[var_host].openPorts[var_port].service.toString();
Port.create({«number»: str_port, «description»: str_desc})
.then((newPort)=>{
const nSNOP = {
«portId»: newPort.id,
«netObjectId»: newNetObject[0].id,
«scanId»: newScan.id
};
ScanNetObjectPort.create(nSNOP)
.then(() => {
console.log(«Port «+str_port+» for host «+IPaddr+» is added to database»);
})
.catch ((error)=> {console.log(«Error on create: newScanNetObjectPort: n»+error.message)});
})
}
}
})
.catch ((error)=> {console.log(«Error on create: newNetObject: n»+error.message)});
})
.catch ((error)=> {console.log(«Error on create: newScan: n»+error.message)});
});
nmap_scan.on('error', function(error){
console.log(error);
});
nmap_scan.startScan();
};
В качестве бонуса в консоли видим SQL-запросы, которые за нас реализовал ORM Sequelize:
Сканирование по таймеру делаем просто. Напишем в nmap.controller.js функцию get_list, которая возвращает массив IP-адресов объектов сканирования, затем, используя setInterval, проводим сканирование всех элементов данного списка в заданный период времени.
exports.get_list = async()=>{
let NetObject_list = await NetObject.findAll();
let list = [];
await NetObject_list.forEach((NO)=> {
list.push(NO.IP.toString());
});
return list;
};
async function array_scan () {
nmap.get_list().then((scan_list)=>{
scan_list.forEach((scanListKey) =>{
nmap.scan_art(scanListKey.toString());
});
})
}
setInterval(array_scan, 360000);
Генерация отчета по результатам сканирования
Процедура генерации отчета с ORM Sequelize довольно проста. Функция генерации отчета получает на вход IP-адрес, затем ищет по данному IP-адресу сетевой объект. Если объект есть в таблице NetObject, то по его первичному ключу методом findAll объекта ScanNetObjectPort мы находим список портов по всем сканированиям. В отчет необходимо вывести дату каждого сканирования, поэтому организуем цикл перебора всех объектов ScanNetObjectPort, с учетом даты текущего сканирования. Информация о каждом сервисе на каждом открытом порту выводится в отчет, в подзаголовке пишется дата сканирования. Отчет в тексте выглядит так:
Report for 127.0.0.1
Date: Tue Dec 15 2020 16:53:18 GMT+0300 (Moscow Standard Time)
Port 22 with service ssh
Port 5432 with service postgresql
Port 8080 with service http-proxy
Date: Tue Dec 15 2020 18:25:50 GMT+0300 (Moscow Standard Time)
Port 22 with service ssh
Port 5432 with service postgresql
Port 8080 with service http-proxy
Вот так отчет составляется:
exports.test_report = (IPaddr) => {
let text_report = «»;
if (IPaddr === undefined) {console.log(«IPaddr is undefined..!»); return;}
let report_promise = new Promise(function (resolve, reject) {
NetObject.findOne({where: {«IP»: IPaddr}}).then(function (NOp) {
if (NOp === null) {console.log(«Not found NetObject by IP «+ IPaddr); return;}
text_report += «Report for « + NOp.IP.toString() + «n»;
ScanNetObjectPort.findAll({where:{«netObjectId»: NOp.id}, include: [{model: Scan}, {model: Port}]})
.then(async function (sel) {
let scan_id = sel[0].scanId; // запоминаем id первого найденного скана как текущий
let scan_date = null;
await Scan.findByPk(scan_id).then(function (scan1) {
scan_date = scan1.date;
});
text_report += «Date: «+ scan_date.toString() +»n»;
for (const item of sel) {
if (item.scanId !== scan_id) { // проверяем, текущий ли скан
scan_id = item.scanId;
await Scan.findByPk(scan_id).then(function (scan1) {
scan_date = scan1.date;
text_report += «Date: «+ scan_date.toString() +»n»;
});
}
await Port.findByPk(item.portId).then (function (p) {
text_report += «Port «+ p.number + « with service « + p.description +»n»;
});
}
return sel;
})
.then (() => { resolve(text_report);})
.catch((error) =>{console.log(«Error on: ScanNetObjectPort.findAll» + error.message); reject(error);});
});
});
Отправка отчета в Telegram
Используем пакет telegraf для Node.JS, чтобы работать с ботом в Telegram. В отдельный модуль для работы с telegram помещаем токен бота и несколько строчек кода для команд типа помощи. Далее через Bot Father создаем свою команду report, по который будем генерировать отчет по хосту во второй части команды.
exports.run = async ()=> {
bot.command('report', (ctx) => {
const netObjectName = ctx.message.text.split(» «)[1];
if (netObjectName === undefined) {return;}
let promise = new Promise(async function (resolve, reject) {
let var_report = await report.get_test_report(netObjectName.toString());
resolve(var_report);
});
promise.then(function (result) {
ctx.reply(result.toString())
.then(()=>{console.log(«report generating successful!»)})
.catch((error)=>{console.log(«Error on report promise» + error)});
},
function (error) {
ctx.reply(«error on generating report...» + error).then(()=>{console.log(«error on generating report...» + error)});
});
});
bot.launch().then (function () {
console.log(«telegram bot is started»);
})
};
Вот так выглядит итоговый отчет:
Выложил проект на github, по традиции, оставив токен в первом коммите. Для запуска необходимо установить на хост nmap, PostgreSQL, настроить подключение к БД, указать токен для бота в Telegram. Запустить приложение и GET-запросом /nmap?IP=127.0.0.1 с идентификатором хоста выполнить сканирование. Хост сразу попадет в базу и далее по таймауту будет производиться сканирование портов с сохранением данных в БД. Новый хост для сканирования добавляется аналогичным образом. Отчеты смотреть через бота, по запросу типа /report <IP/URL>, где IP/URL – идентификатор хоста для сканирования.
Итоги
По сравнению с решением n8n, которое я пробовал ранее, программирование на Node.JS показалось мне намного сложнее. Для написания такого простейшего приложения пришлось изучить много новых вещей: от ORM до промисов в Node.JS. Эти вещи оказались немного сложными для понимания, даже учитывая имеющийся небольшой опыт программирования и опыт работы с фреймворками типа Vue.JS или Modx. Очень понравилось использование ORM, для небольших задач он незаменим.
Резюме
Если при решении вашей задачи есть возможность обойтись без программирования, используйте решения типа n8n. Если же решите воспользоваться описанной технологией, то придется изучать JS.
Готов ответить на вопросы. Пишите на arudakov@lanit.ru.
Кстати, у нас еще есть вакансии!
- Инженер по информационной безопасности
- Архитектор по информационной безопасности
- Консультант/старший консультант по информационной безопасности
- Специалист по информационной безопасности
Автор: arudakov