PatchFirst / Документация
Оповещения через вебхук
Для платного мониторинга можно настроить один HTTPS-адрес на парк. PatchFirst отправляет на него выбранные события методом POST с Content-Type: application/json. Полные находки и рекомендации остаются в кабинете.
Как включить
- Откройте в кабинете «Оповещения», выберите парк с действующей оплатой и режим «Вебхук».
- Укажите публичный HTTPS URL: по умолчанию порт 443, другой порт нужно указать явно. Не включайте секреты или персональные данные в URL.
- Выберите события и число часов без инвентаря для оповещения о молчащем сервере.
- Сохраните созданный сервисом секрет в защищённом хранилище получателя. Он показывается один раз. Используйте «Отправить тестовое», чтобы проверить интеграцию.
При перевыпуске сохраните новый секрет у получателя: старый перестаёт действовать. Изменение настроек отменяет ожидающие доставки; уже начавшаяся отправка может завершиться. Сохранение без изменений сохраняет ожидающие повторы и состояние оповещений о молчащих серверах. Без действующей оплаты настройка и отправка недоступны.
События
| Событие | Когда отправляется |
|---|---|
finding.new | Новая находка уровня P0 или P1: первое появление компонента на сервере или новая CVE у него. |
finding.escalated | Уровень уже известной находки повысился до P0 или P1. |
host.kev | В находке появилась CVE, которая сама входит в каталог KEV. |
host.rating_dropped | Буквенный рейтинг сервера ухудшился относительно последней известной буквы, включая переход через «?». |
host.inventory_stale | Сервер не присылал инвентарь дольше заданного числа часов. |
webhook.test | Владелец нажал «Отправить тестовое». |
События возникают после приёма инвентаря или пересчёта по обновлённым фидам. Повторный пересчёт без изменения состояния не создаёт повторное событие. Повторная доставка одного события сохраняет его id.
Ключ находки: сервер + компонент/пакет. В одном finding.new перечисляются главные CVE находки; событие приходит при первом появлении или если у компонента появилась новая CVE. Смена версии без новых CVE и перестановка главных CVE не создают событие. Учитывается полный набор CVE, включая находящиеся за пределами трёх главных. Повышение уровня существующей находки отправляется как finding.escalated. Понижение и обратное повышение в пределах 24 часов не вызывают повторного оповещения о том же уровне. Если находка исчезла и вернулась спустя 24 часа или больше, приходит finding.new. При включении вебхука или возобновлении оплаты история инициализируется текущим состоянием без рассылки; явная тестовая отправка доступна отдельно. Пока вебхук выключен или не оплачен, история продолжает обновляться. Для новых находок и повышения уровня в кабинете предусмотрены отдельные настройки событий.
host.kev отправляется только для CVE с собственным признаком KEV, один раз на пару «сервер, CVE», даже если она входит в несколько находок. Общий признак KEV у находки не распространяется на остальные CVE. При переходе рейтинга B → ? → F событие содержит before: B и after: F: «?» не сбрасывает последнюю известную букву.
JSON
{
"event": "finding.new",
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"created": 1792756800,
"fleet": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"host": {
"id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"name": "acme-1"
},
"summary": {
"cves": ["CVE-2026-12345", "CVE-2026-12346"],
"level": "P0",
"component": "acme",
"version": "1.2.3"
},
"url": "/app/#servers"
}
id— UUID события, совпадает сX-PatchFirst-Delivery;created— время создания, целое число секунд Unix UTC;fleet— UUID парка.host— объект с UUID сервера илиnullдля теста.nameприсутствует только для открытого условного имени. Зашифрованные имена не раскрываются и не отправляются. Открытое условное имя читается при отправке и может меняться между попытками; очередь хранит только UUID.summary— минимальный объект по типу события. Дляfinding.newиfinding.escalated:cves(список главных CVE, до трёх),level,componentиversion(установленная версия). Дляhost.kev:cve(одна CVE из KEV),level,componentиversion. Если установленная версия неизвестна,versionсодержит пустую строку. Для рейтинга:beforeиafter; для отсутствия инвентаря:hoursиlast_received(секунды Unix UTC); для теста:messageсо значениемPatchFirst webhook test. Получатель должен допускать новые поля.url— относительная ссылка в кабинет на этом сайте; для перехода нужен вход. Полный инвентарь, словарь имён, токены и клиентские данные в сообщении отсутствуют.
Подпись запроса
Перед обработкой проверьте все четыре заголовка:
X-PatchFirst-Timestamp— целое число секунд Unix UTC для текущей попытки.X-PatchFirst-Signature— строкаsha256=<hex>: HMAC-SHA256 отtimestamp + "." + body. Timestamp кодируется ASCII, тело — исходные байты HTTP, секрет — ровно строка из кабинета в UTF-8. Секрет не нужно декодировать из base64.X-PatchFirst-Event— тип события, должен совпадать сeventв проверенном JSON.X-PatchFirst-Delivery— UUID доставки, должен совпадать с подписаннымid. При ретраях он остаётся прежним.
Отклоняйте timestamp дальше чем на 300 секунд в любую сторону от часов получателя и сравнивайте подпись за постоянное время. Синхронизируйте часы. Проверяйте исходные байты до разбора JSON: повторная сериализация, смена пробелов или Unicode меняет подпись. При ретраях timestamp и подпись обновляются, время created остаётся прежним.
Python 3
Скачать verify_webhook.py. Только стандартная библиотека. Передайте request.body как bytes и заголовки в receive_webhook.
Открыть исполняемый пример Python
"""PatchFirst receiver example (Python 3, standard library only).
Pass the original request bytes, before JSON parsing. The secret is the exact
UTF-8 string shown in the dashboard; do not base64-decode it.
"""
import hashlib
import hmac
import json
import re
import time
def verify_webhook(secret, body, headers, *, now=None):
"""Return the authenticated event or raise ValueError. No side effects."""
headers = {key.lower(): value for key, value in headers.items()}
timestamp = headers.get('x-patchfirst-timestamp', '')
signature = headers.get('x-patchfirst-signature', '')
if not re.fullmatch(r'[0-9]{1,12}', timestamp):
raise ValueError('Invalid timestamp')
if abs((time.time() if now is None else now) - int(timestamp)) > 300:
raise ValueError('Expired timestamp')
if not re.fullmatch(r'sha256=[0-9a-f]{64}', signature):
raise ValueError('Invalid signature format')
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'), timestamp.encode('ascii') + b'.' + body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError('Invalid signature')
event = json.loads(body)
if not isinstance(event, dict) or not isinstance(event.get('id'), str) or not event['id']:
raise ValueError('Missing delivery id')
if event['id'] != headers.get('x-patchfirst-delivery'):
raise ValueError('Delivery id mismatch')
if event.get('event') != headers.get('x-patchfirst-event'):
raise ValueError('Event mismatch')
return event
def receive_webhook(secret, body, headers, enqueue_unique, *, now=None):
"""Verify, then persist once. Return True for new, False for duplicate.
enqueue_unique(id, event) MUST atomically persist the event to a durable
queue with a UNIQUE constraint on id, returning False for an existing id.
Only return HTTP 2xx after this call succeeds. Queue consumers perform
business actions transactionally or with the same idempotency key.
"""
event = verify_webhook(secret, body, headers, now=now)
return enqueue_unique(event['id'], event)
Node.js
Скачать verify_webhook.js. Только встроенный node:crypto. Передайте исходное тело как Buffer до middleware, разбирающего JSON, в receiveWebhook.
Открыть исполняемый пример Node.js
'use strict';
// PatchFirst receiver example (Node.js, built-in modules only).
const {createHmac, timingSafeEqual} = require('node:crypto');
function verifyWebhook(secret, body, headers, now = Date.now() / 1000) {
// body must be the original Buffer, before JSON parsing.
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
const timestamp = h['x-patchfirst-timestamp'];
const signature = h['x-patchfirst-signature'];
if (typeof timestamp !== 'string' || !/^[0-9]{1,12}$/.test(timestamp)) {
throw new Error('Invalid timestamp');
}
if (Math.abs(now - Number(timestamp)) > 300) throw new Error('Expired timestamp');
if (typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(signature)) {
throw new Error('Invalid signature format');
}
// Use the exact UTF-8 secret shown in the dashboard; do not base64-decode.
const digest = createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(Buffer.from(timestamp + '.', 'ascii')).update(body).digest('hex');
const expected = Buffer.from('sha256=' + digest, 'ascii');
const received = Buffer.from(signature, 'ascii');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
throw new Error('Invalid signature');
}
const event = JSON.parse(body.toString('utf8'));
if (!event || typeof event.id !== 'string' || !event.id) throw new Error('Missing delivery id');
if (event.id !== h['x-patchfirst-delivery']) throw new Error('Delivery id mismatch');
if (event.event !== h['x-patchfirst-event']) throw new Error('Event mismatch');
return event;
}
async function receiveWebhook(secret, body, headers, enqueueUnique, now = Date.now() / 1000) {
const event = verifyWebhook(secret, body, headers, now);
// enqueueUnique MUST atomically persist to a durable queue with UNIQUE(id).
// Return false for duplicates; return HTTP 2xx only after persistence succeeds.
// Queue consumers use transactions or the same idempotency key for effects.
return await enqueueUnique(event.id, event);
}
module.exports = {verifyWebhook, receiveWebhook};
Идемпотентность и ответ получателя
В примерах enqueue_unique(id, event) / enqueueUnique(id, event) — ваша функция надёжной очереди. Она должна атомарно записать проверенное событие с уникальным ограничением на id и вернуть false для дубля. Используйте постоянное хранилище, а не набор в памяти. Сохраняйте ключи не менее 48 часов. Обработчик очереди должен выполнять действия транзакционно либо использовать тот же id как ключ идемпотентности.
Возвращайте 2xx после успешного сохранения события или распознавания уже сохранённого дубля. При ошибке подписи возвращайте 4xx; при сбое сохранения — 5xx. Не отвечайте успехом до надёжного сохранения. Содержимое ответа PatchFirst не использует.
Доставка и ограничения
Отправка идёт из отдельного воркера и не задерживает API. Разные получатели обслуживаются параллельно, чтобы медленный получатель не задерживал другие парки. Любой 2xx означает успешную доставку. Временные сетевые ошибки, ошибки TLS (включая проверку сертификата) и ответы 5xx вызывают ретраи: паузы 60, 120, 240 секунд и далее с удвоением до 21600 секунд (6 часов). Срок доставки — 86400 секунд (24 часа) от создания события; следующая попытка за этим пределом не планируется. Ответ 410 Gone завершает доставку сразу; при остальных 4xx допускается не более 3 попыток всего. Ответы 3xx, небезопасный адрес и слишком большой ответ завершают доставку без повторов. Повторы возможны, порядок не гарантируется, доставка не является гарантированным каналом экстренного реагирования. После исчерпания попыток проверьте журнал и свой обработчик.
В очереди парка может ожидать не более 1000 доставок. При переполнении самые старые ожидающие события отбрасываются с записью в журнал. Сохранение настроек ограничено: 10 сохранений в минуту на парк.
Переходы по redirect запрещены. Адрес проверяется после DNS-разрешения и при каждом соединении: приватные и зарезервированные IPv4/IPv6, loopback, link-local, CGNAT и metadata-адреса блокируются. Также запрещены публичные адреса самого сервиса: его домен и IP. Уже при сохранении отклоняются localhost и нестандартные числовые, восьмеричные и шестнадцатеричные формы IPv4. Для отклонённых адресов журнал показывает только «недоступен». Используйте только публичный получатель; действуют сетевой таймаут 5 секунд, ограничение ответа 65536 байт (64 КиБ) и лимит частоты на парк. Ответ 3xx считается ошибкой, новый адрес не посещается.
Последние доставки видны в «Оповещениях»: время, событие и HTTP-код ответа, если он получен. Тело ответа не сохраняется. Регулярно проверяйте кабинет и состояние своего получателя.