1. Вступление
  2. Бот за 10 минут
    1. getRandomId
    2. isIdValid
    3. getMetadata
    4. postReport
    5. Можно ли писать хуки на другом языка?
    6. Запускаем бота

Вступление

Мы не знаем того, чего не знаем. Со времен Сократа мы узнали многое, но верность данного утверждения не изменилась ни на йоту. Зачастую у нас нет иного способа подступиться к огромному массиву данных иначе, чем начать наугад выдергивать из него конкретные сущности. Рандомскан это невод, запущенный в мир высокоэнтропийного хаоса. Но этот хаос, задумкою нетсталкера, создающего рандомсканер, обретает некое более узкое фазовое пространство. Позволяет начать ориентироваться в элементах того множества, которое сканируется. Эти элементы, артефакты рандомскана, очищаются от мишуры, наполняются только релевантными метаданными и становятся насканом, из которого потом оператор отсортировывает по-настоящему ценные находки. Рандомсканинг это выглядывание в очерченные ботами/каналами горизонты некоторых массивов информации. Это одновременно очень медитативное занятие и, в то же время, единственный способ как-то начать перебирать огроменные каталоги чего угодно.

К примеру имгур и лайтшот. Там потенциально много интересного, но мы не представляем даже характер этого “интересного”. Искать ли НЛО, фото кредиток, или обнаженку? Потому мы будем просто тянуть все подряд на угад и оценить, нет ли среди выхлопа чего интересного. Такой подход позволят получить результаты здесь и сейчас, без разработки (зачастую невозможной) методики автоматического поиска годноты. Возможно в дальнейшем мы поймем, что интересует нас конкретно и научим сканер автоматически отделять зерна от плевел. На первых этапах это всегда просто любопытство.

Рандомсталк это, если хотите, нетсталкрский и-цзин. Ты просто отдаешься оракулу и идешь по неведомой тропинке, которая заведет тебя или на сайт детского мультика “феи Винкс” или в дебри гофер-сайта с секретными доками правительства. Если это бот, постящий инфу порциями или по расписанию, то это прикольный сервис, если угодно, для “плодотворной прокрастинации”. Когда хочется отвлечься от работы, куда полезнее (имхо) заглянуть в выдачу бота Random FTP Image, чем в ленту Фейсбука. В частности канал Random Open Science стал для меня быстрыми кейсами по OSINTу. Определить принадлежность данных, характер данных, и возможные способы применения данных - задачка на 10-15 минут, зашли и вышли, разминка для хвоста.

Исходя из вышеперечисленного, рандомсталк требует выделения (интуитивно) множества из которого будут тянуться находки и способа выбора находки.

Например:

  • FTP-бот - просто скан Интернет-пространства на предмет наличия FTP-серверов с anonymous-логином и вытягиванием с них первого попавшегося;
  • Каталог Sitecity.RU - выбор рандомной записи в БД, которая составлена путем скраппинга каталога;
  • Hidden-service в TOR - генерация рандомного хеша (не лучший метод, но метод).

Почти всегда рандомсталк требует более удобной “доставки” находки, чем механизм ручного перебора. Наскан FTPшников можно было бы делать просто из выхлопа masscan, перебирая его руками и вкидывая в точку, но тогда не было бы конвейера. Не было бы стандартизированных сообщений бота, которые пересылаются, распихиваются по тематическим каналам, хранятся где-либо. Скриншоты лайтшота перебирать можно и вручную, но это развлечение на пару часов. Рандомсталк же, как мне кажется, это всегда континуальный процесс, идущий где-то параллельно и звонящий в колокольчик “эй, нетсталкер, смотри, новая находка, как она тебе?”.

В итоге поучается приятный баланс ручной и автоматической работы. По крайней мере из всех начинаний в нетсталкинге именно такое соотношение перебора и механизации работает лучше всего - примером тому множество каналов, которые либо живы, либо жили длительное время - rghost, googledocs, Random FTP Image, 192.168.1.1, и прочие.

Тут и проявляется особый образ мышления. Когда нетсталкер видит масив информации, например скриншоты на сервисе, у него в голове проскакивает искра: “о, неплохо бы покопаться тут на удачу, посмотреть, что тут лежит в среднем”. Сталкер делает бота, запускает и все наслаждаются новым источником артефактов-находок. Я веду к тому, что на самом деле нетсталкинга это не про написание кода, а про изучение информации, на которую по разным причинам обращается недостаточное внимание. Нетсталкер цепко пытается увидеть что-то большее на старинном сайте про подростковые сериалы или случайном скриншоте китайского сериала на FTP. Потому он перебирает, пускай и наугад, эти сервера и сайты. В конце-концов, машины машинами, но смыслы могут создаваться только людьми и периодическое вглядывание в бездну инфы (через рандомскан каналы) это один из основных путей нетсталкинга в целом.

Я захотел сделать шаг навстречу тем, у кого есть идеи, но нет времени/навыков программирования и написал заготовку бота для создания рандомсталк-канала. Бот этот не является сканером в общем случае, хотя его можно обучить рандомному выбору элемента из множества. В первую очередь он разрабатывался для работы в связке с более серьезными сканерами, вроде nmap/masscan. В его задачи входит автоматический сбор всех доступных метаданных об артефакте и постинг небольшого отчета в телеграм-канал. Если инструмент будет использоваться сообществом, я подумаю над добавлением других возможностей взаимодействия с ботом, например веб-апи или возможность подписки на AMQP сообщения.

Бот за 10 минут

Для примера использования инструмента, продемонстрируем его в работе. Сайт LightShot для многих стал вратами в нетсталк. Даже мои далекие от темы знакомые рассказывали, что тратили часы, перебирая чужие картинки по хешам. Сейчас покажу, как собрать бота для постинга рандомной картинки раз в 15 минут. Весь код из этой статьи доступен в репозитории в разделе примеры.

Клонируем Скачиваем и распаковываем скелет из дистрибутива с репозитория проекта netwhood.online в директорию rndscht. Директория должная существовать:

wget -qO- https://git.netwhood.online/pan_kotowski/randor/raw/branch/master/distribution.tar | tar -xz -C ./rndscht

Тут опционально можно удалить директорию .git, файлы README.org и README.md. Скорее всего заточенный под конкретную задачу бот будет храниться уже в другом репозитории. В ближайшее время я хочу доделать отдельную сборку дистрибутива, только с нужными для разработки своего бота файлами.

Скелет бота основывается на потоке подготовки отчета. На основе идентификатора некой сущности наполняется структура необходимых метаданных и создается текстовый отчет с вложениями, который постится (по умолчанию) в телеграм. Перед разработчиком, использующим скелет, встают только “творческие” задачи - написание хуков, самой логики бота-сканера.

Стоит заметить, что идентификатором может быть что угодно. При работе со случайными записями в заранее подготовленном каталоге, идентификатором будет внутренний id cущности в каталоге. Так, например, было с ботами для Sitecity.RU и CHAT.RU. В случае LightShot идентификатором, очевидно, будет код скриншота из адреса соответствующей веб-странички. Т.е. pokn3r будет идентификатором для сущности по адресу https://prnt.sc/pokn3r.

Метаданными, в нашем случае, является, во-первых, сама картинка - ее бот будет прикреплять к каждому отчету. Картинку можно доставать по известному XPath из <body> документа, а можно заглянуть в <header>, нафаршированный микроформатами. В числе прочего, там лежит RDF описание сущности в словаре Facebook Open Graph, записанное в теги <meta>. Если проще:

<meta property="og:site_name" content="Lightshot">
<meta property="og:title" content="Screenshot">
<meta property="og:image" content="https://image.prntscr.com/image/A0BZzRBuTr6l8PnrdYAtdw.png">
<meta property="og:description" content="Captured with Lightshot">
<meta property="og:url" content="https://prnt.sc/poknzg">

Такие же описания можно найти и для твиттера. Я предпочту OG просто потому что увидел его первым в теле страницы. Пролистав наугад несколько скриншотов, я не увидел разницы в полях og:title или og:description, но интуиция подсказывает, что лучше их собирать - возможно где-то они будут заданы пользователем и мы увидим что-нибудь интересное. В конечном итоге, если заголовок и описание будут сильно мозолить глаз - уберем. Ну и как минимум мы будем сохранять og:url изображения, все-таки первоочередными сущностями для нас являются странички сервиса, а не файлы.

Отчет будет представлять из себя, собственно, картинку с описанием и ссылкой:

`%title% - %description% \n %url%`

На данный момент бот ожидает четырех хуков - функций, которые, будучи объявленными разработчиком, будут запущены в ключевых стадиях формирования отчета:

  • getRandomId - генератор случайных идентификаторов. Не всегда это представляется возможным, но в случае LightShot мы можем выявить некоторые закономерности в адресах страниц. Как минимум (на данный момент) все идентификаторы это цифро-буквенные комбинации, не более 6 символов в длину. Судя по отслеживанию скриншотов в твиттере (прямо на главной LightShot), идентификаторы выдаются просто инкрементально или около того

prntsc_hash

Поиграв со значениями pokn12 и pokn1 видим, что существуют валидные пятисимвольные идентификаторы.

  • isIdValid - валидатор идентификатора, если мы будем пользоваться генератором случайных адресов на prnt.sc, мы рискуем не угадать и сгенерировать адрес сущности, которой не существует. К сожалению, сервис не возвращает адекватных HTTP статусов в случае, если мы промахнулись и запросили не существующий скриншот. Он просто покажет плашку-отбивку “The screenshot was removed”.

scrnshtremoved

  • getMetadata - функция наполняет структуру, хранящую запись о находке нужными метаданными. Мы уже решили что будем сохранять url картинки, title и description из секции <head> веб страницы.

  • postReport - хук для трансформации записи о находке в формат отчета и отправка самого поста. В заготовках бота есть функция постинга через бот-апи телеграма, воспользуемся ей. Сформируем текстовый отчет на основании метадаты и прикрепим файл.

Стоит заметить, что каждый из хуков, сам по себе, не является чем-то обязательным. Лего представить ситуацию, когда, например, валидация ресурса не требуется - все сайты каталога SiteCity являются валидными адресами на ресурсе и бот может сразу перейти к сбору метаданных, isIdValid не нужна. В случае перебора хешей на каком-нибудь сервисе хранения документов может приключиться ситуация, когда никаких метаданных, кроме, собственно, валидного хеша, собрать не получится - в таком случае getMetadata является ненужной. Без postReport бот не сможет выполнить свое основное предназначение и просто проплюется результатами работы в stdout. Если бот работает в связке с внешним сканером, например masscan, ищущий гоферхолы, очевидно что он будет запускаться внешней программой с уже переданым идентификатором ресурса, для такого случая в getRandomId нет необходимости.

Транспортом №1 является постинг отчетов через телеграм - собственно мы же телеграм бота разрабатываем. Для удобства, бот уже имеет встроенный модуль для работы с API телеграма (однако на момент написания статьи модуль умеет только постить сообщения или фото в чат, впрочем пока что иного от него не требуется). Бот пока что имеет два режима работы.

  • Прямой режим через CLI. Вы запускаете бота, передавая ему идентификатор сущности и он, выполняя единоразовый проход по цепочке, постит сообщение (при удачном исходе)

  • Крон-режим. Вы запускаете бота, предварительно снабдив его хуком getRandomId() для самостоятельной генерации или получения идентификаторов и созданием отчетов по расписанию. Скорее всего, в дальнейшем дефолтный режим бота будет изменен на прослушку веб портов или очереди AMQP - когда это случится, это обязательно будет отображено в руководстве. README файл всегда свежее этой статьи, заглядывайте в него.

Бот ожидает что вышеперечисленные функции будут находиться в корне проекта, в файле hooks.js. Потому если вы решили пользоваться примером из репозитория, вам просто нужно скопировать его в корень проекта. Если нет - создайте свой файл и приступим к его наполнению.

getRandomId

С этой функцией все достаточно просто. У нас нет точных данных про алгоритм генерации хешей, потому будем генерировать случайную строку, согласно нашим наблюдениям. Если в дальнейшем получится отреверсить процесс присвоения хеша скриншоту, просто обновим эту функцию

function getRandomId() {
  const len = Math.round(Math.random()) ? 5 : 6;
  const randChar = () => Math.random().toString(36).slice(-1);

  return Array.apply(null, Array(len)).map(() => randChar()).join("");
}

Сразу замечу, что в случае генерации не валидного идентификатора, флоу все равно запустится и прервется на этапе валидации. Важно понимать это в контексте крон-режима. В таком случае, бот будет по расписанию генерировать идентификатор и передавать его на конвейер. Если идентификатор окажется не валидным - постинга не произойдет. Для стабильного постинга по крону необходимо включить этап валидации в генерацию идентификатора. Валидация - важный вопрос, не всегда это возможно и нужно делать на этапе генерации идентификатора. Но поскольку в нашем случае все равно предполагается написание функции-валидатор, можно просто добавить бесконечный цикл (а лучше с ограниченным набором попыток) запуска функции isIdValid по отношению к только что сгенерированному идентификатору прямо в теле getRandomId. Получилось бы что-то в таком духе

function getRandomId() {
  const randomId = () => {
    const len = Math.round(Math.random()) ? 5 : 6;
    const randChar = () => Math.random().toString(36).slice(-1);

    return Array.apply(null, Array(len)).map(() => randChar()).join("");
  }
  let retryCounter = 10; //Ten retrys, ok?
  // init values
  let id = randomId();
  let isValid = isIdValid(id);

  while (retryCounter > 0 && !isValid) {
    id = randomId();
    isValid = isValid(id);
    retryCounter--;
  }

  if (isValid) return id;
}

isIdValid

Этот и другие хуки, связанные с основным флоу, должны быть асинхронными функциями. Причина очевидна - сетевые запросы требуют ощутимого времени, в то же время нам необходимо дождаться конца валидации/наполнения метаданными/постинга в телеграм, прежде чем переходить к следующей фазе процесса.

Воспользуемся библиотекой axios для выполнения запросов. Она позволяет писать более читаемый код, практически не тащит зависимостей. Но в то же время axios не очень гладко работает с нодой, например на момент написания статьи, отправить файл POST запросом без костылей и акробатики не видится возможным. Потому во встроенном клиенте телеграм-апи используется встроенная библиотека request. Она менее пригодна для изящных и асинхронных конструкций, мы прячем ее за кулисами.

Валидация заключается в запросе странички и сравнении картинки в ответе с эталонной картинкой “The screenshot was removed”. С помощью библиотеки cheerio мы легко вытащим нужные данные из HTML документа, а pixelmatch поможет выполнить сравнение картинок попиксельно.

Напишем небольшой хелпер для сохранения картинок и очистки рабочего пространства от них по окончанию флоу. Сразу же зададим дефолтное имя сохраняемой картинки - dwnld.png. Это не точно, но предполагается, что сервис оперирует только png файлами:

const fs = require('fs');

// Pixel comparison
const PNG = require('pngjs').PNG;;
const pixelmatch = require('pixelmatch');

// Web scrapping
const cheerio = require('cheerio');

// Requests
const axios = require('axios');

const downloadImage = (url, imagePath) =>
      axios({
        url,
        responseType: 'stream',
      }).then(
        response =>
          new Promise((resolve, reject) => {
            response.data
              .pipe(fs.createWriteStream(imagePath))
              .on('finish', () => resolve())
              .on('error', e => reject(e));
          }),
      );

const deleteImage = (imagePath) => {
  fs.unlink(imagePath, function(err) {
    if(err && err.code == 'ENOENT') {
      // file doens't exist
      console.info(`${imagePath} did not existed`);
    }
  });
}

const IMAGE_PATH = './dwnld.png';

Теперь напишем сам валидатор.

async function isIdValid(id) {
  // clear before previous use
  deleteImage(IMAGE_PATH);

  /* GET page */
  const response = await axios.get(`https://prnt.sc/${id}`);
  if (response.status !== 200) return false; // Validation failed
  /* Load scrapper, extract url from
   * <head><meta property="og:image" content=...>
   */
  const $ = await cheerio.load(response.data);
  const url = $('head meta[property="og:image"]').attr("content");
  console.log('url:', url)
  /* Download img and save it to file */
  if (!url) return false; // Validation, obviously, failed

  await downloadImage(url, IMAGE_PATH);

  /* Compare images */
  const base = PNG.sync.read(fs.readFileSync('./base.png'));
  const dwnld = PNG.sync.read(fs.readFileSync(IMAGE_PATH));

  const {baseWidth, baseHeight} = base;
  const {width, height} = dwnld;

  if (baseWidth === width && baseHeight === height) {
    /* Looks suspicious */
    const diffNum = pixelmatch(base.data, dwnld.data, null, width, height, {threshold: 0.1});

    if (diffNum < 10) { // pretty accurate, huh?
      /* Assume images being identical
       */
      deleteImage(IMAGE_PATH);
      return false;
    }
  }

  /* Assuming that resource is valid */
  return true;
}

getMetadata

Дополним артефакт метаданными из html документа. Мы снова выполним запрос, как в предыдущей функции, и вытянем с помощью cheerio содержимое атрибута content у интересующих нас метатегов. Cheerio работает с XPath селекторами, ничего нового.

async function getMetadata(artifact) {
  if (!artifact.id) throw new Error('Invalid argument');

  const response = await axios.get(`https://prnt.sc/${artifact.id}`);
  if (response.status !== 200) return artifact; // Validation failed, no metadata could be extracted

  const $ = await cheerio.load(response.data);

  artifact.imageUrl = $('head meta[property="og:image"]').attr("content");
  artifact.title = $('head meta[property="og:title"]').attr("content");
  artifact.description = $('head meta[property="og:description"]').attr("content");

  if (!fs.existsSync(IMAGE_PATH)) {
    await downloadImage(artifact.imageUrl, IMAGE_PATH);
  }
  artifact.imageFile = IMAGE_PATH;

  return artifact;
}

postReport

К этому моменту у нас должна быть структура, наполненная данными про артефакт. Самое время сформировать читаемый отчет и отправить его в наш канал

const telegram = require('./src/telegram.js');

async function postReport(artifact) {
  let caption = `<code>${artifact.id}:</code> <a href="https://prnt.sc/${artifact.id}">${artifact.title} - ${artifact.description}</a>`

  telegram.post(caption, fs.createReadStream(artifact.imageFile));

  return artifact;
}

Дефолтным режимом парсинга сообщений в телеграм клиенте стоит html. Шаблон сообщения самоочевиден - “id - ссылка на картинку”.

Не смотря на то, что это последний из запускаемых хуков, postReport все равно возвращает артефакт на тот случай, если потребуется выполнить сохранение результатов. Пропатчив src/flow.js:149: можно дополнительно записывать в базу данные о всех удачных (и неудачных) проходах вместе с метаданными. Это позволить добавить в валидатор простую проверку на дубликат и не постить одни и те же сущности в канал несколько раз. Добавив телеграм боту обратную связь в виде кнопок голосования, можно внедрить систему рейтинга контента - для такого сценария так же потребуется сохранение артефактов после окончания работы основного флоу.

Можно ли писать хуки на другом языка?

Определенно, но для взаимодействия между нодой все равно придется определить хук, поместив в него вызов скрипта на другом языке и маршализацию/демаршализацию данных передаваемых скрипту или возвращаемых им. Или же использовать внутри хука готовые обертки, вроде Slurpy.

Как меня найти вы знаете.

Запускаем бота

Бота можно запустить в локальном окружении, для этого вам потребуется установить сам интерпретатор JavaScript node и пакетный менеджер npm для установки зависимостей. Этот этап сильно зависит от используемой операционной системы, оставляю его на вашей совести.

Для установки всех пакетов в директории проекта достаточно выполнить команду

npm i

Встроенная функция постинга в телеграм ожидает двух переменных окружения - BOT_TOKEN и CHANNEL_ID с соответствующими значениями. Для постинга в один канал хватает настроек бота, с которыми он создается по умолчанию. Нужно отдельно создать канал и добавить туда бота на правах администратора (иначе вы и не сможете). Для постинга потребуется название канала (включая “собачку”) или id канала, если он приватный или если это просто личный чат. Таким образом команда запуска бота в режиме cron будет выглядеть так:

BOT_TOKEN="<токен вашего бота>" CHANNEL_ID="<id или название (включая собачку) вашего канала>" node index.js

В проект так же включен дефолтный Dockerfile - конфигурация для сборки докера с проектом внутри, для изоляции окружений и удобного деплоя на сервера. Конфигурация достаточно самоочевидная и в комментариях в файле я положил подсказки, которые продублирую и тут.

Для сборки контейнера с установленными переменными окружения выполните:

docker build --build-arg BOT_TOKEN=%token% --build-arg CHANNEL_ID=%channel_id% -t %tag% .

Для запуска:

docker run -d -t %tag%

Внимание! В докере всегда запускается режим по-умолчанию. Сейчас это cron-подобная работа, но скорее всего в дальнейшем это изменится. Всегда сверяйтесь в README.