PatchFirst / Documentación
Notificaciones por webhook
La monitorización de pago admite un destino HTTPS por flota. PatchFirst envía los eventos elegidos mediante POST con Content-Type: application/json. Los hallazgos y las recomendaciones completos permanecen en el panel.
Activar notificaciones
- Abre Notificaciones en el panel, selecciona una flota con monitorización de pago activa y elige Webhook.
- Introduce una URL HTTPS pública. El puerto predeterminado es 443; indica cualquier otro explícitamente. No incluyas secretos ni datos personales en la URL.
- Selecciona los eventos y las horas sin inventario necesarias para notificar que un servidor ha dejado de informar.
- Guarda el secreto generado por el servicio en el almacenamiento seguro de tu receptor. Solo se muestra una vez. Selecciona Enviar prueba para comprobar la integración.
Al renovar el secreto, actualiza el receptor: el anterior deja de funcionar. Cambiar los ajustes cancela las entregas pendientes; un intento ya iniciado puede completarse. Guardar sin cambios conserva los reintentos pendientes y el estado de las notificaciones por ausencia de inventario. Configurar y enviar requiere acceso de pago activo.
Eventos
| Evento | Desencadenante |
|---|---|
finding.new | Nuevo hallazgo P0 o P1: primera aparición de un componente en un servidor o una CVE nueva que lo afecta. |
finding.escalated | El nivel de un hallazgo existente sube a P0 o P1. |
host.kev | Un hallazgo incorpora una CVE que pertenece por sí misma al catálogo KEV. |
host.rating_dropped | La calificación por letra del servidor empeora respecto a la última conocida, incluidas transiciones a través de «?». |
host.inventory_stale | No se ha recibido inventario durante más horas de las configuradas. |
webhook.test | El propietario selecciona Enviar prueba. |
Los eventos se originan en envíos de inventario o recálculos con fuentes actualizadas. Recalcular un estado sin cambios no genera otro evento. Los reintentos de entrega conservan su identificador.
La clave de un hallazgo es servidor + componente/paquete. Un finding.new enumera sus CVE principales y se envía en la primera aparición o cuando una CVE nueva afecta al componente. Un cambio de versión sin nuevas CVE o una reordenación de las principales no genera evento. Se sigue el conjunto completo de CVE, incluidas las posteriores a las tres principales. El aumento de nivel de un hallazgo existente se envía como finding.escalated. Una bajada seguida de vuelta al mismo nivel en 24 horas no genera una notificación repetida para ese nivel. Un hallazgo que desaparece y vuelve tras al menos 24 horas genera finding.new. Activar webhooks o reanudar el pago inicializa el historial con el estado actual sin enviar notificaciones; la prueba explícita está disponible aparte. El historial sigue actualizándose con webhooks desactivados o sin pago. Los nuevos hallazgos y los aumentos de nivel tienen ajustes de eventos independientes en el panel.
host.kev solo se envía para CVE con su propia marca KEV, una vez por pareja «servidor, CVE», aunque aparezca en varios hallazgos. La marca KEV global de un hallazgo no se aplica a sus otras CVE. Para una transición B → ? → F, el evento contiene before: B y after: F: «?» conserva la última letra conocida.
Contenido 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"
}
ides el UUID del evento y coincide conX-PatchFirst-Delivery;createdes la hora de creación en segundos Unix UTC enteros;fleetes el UUID de flota.hostcontiene el UUID del servidor, onullen una prueba.namesolo se incluye si existe un alias almacenado sin cifrar. Los nombres cifrados nunca se revelan ni se envían. El alias se lee al entregar y puede cambiar entre reintentos; la cola solo guarda el UUID.summaryes un objeto mínimo específico del evento. Parafinding.newyfinding.escalated:cves(hasta tres CVE principales),level,componentyversion(versión instalada). Parahost.kev:cve(una CVE de KEV),level,componentyversion. Si se desconoce la versión instalada,versiones una cadena vacía. Para cambios de puntuación:beforeyafter; para ausencia de inventario:hoursylast_received(segundos Unix UTC); para pruebas:messagecon el valorPatchFirst webhook test. Los receptores deben tolerar campos nuevos.urles un enlace relativo al panel de este sitio; exige iniciar sesión. Se excluyen el inventario completo, el diccionario de nombres, los tokens y los datos de clientes.
Firma de la solicitud
Comprueba las cuatro cabeceras antes de procesar:
X-PatchFirst-Timestamp: segundos Unix UTC enteros del intento actual.X-PatchFirst-Signature:sha256=<hex>, el HMAC-SHA256 detimestamp + "." + body. Codifica la marca temporal como ASCII; usa los bytes originales del cuerpo HTTP y el secreto exacto del panel como UTF-8. No descodifiques el secreto como base64.X-PatchFirst-Event: tipo de evento; debe coincidir coneventdel JSON autenticado.X-PatchFirst-Delivery: UUID de entrega; debe coincidir con elidfirmado. Permanece estable entre reintentos.
Rechaza marcas temporales con más de 300 segundos de diferencia, antes o después, respecto al reloj del receptor y compara las firmas en tiempo constante. Mantén los relojes sincronizados. Verifica los bytes originales antes de analizar JSON: los cambios en serialización, espacios o Unicode invalidan la firma. Cada reintento tiene una marca temporal y una firma nuevas; created no cambia.
Python 3
Descargar verify_webhook.py. Solo usa la biblioteca estándar. Pasa el cuerpo original como bytes y las cabeceras a receive_webhook.
Mostrar ejemplo ejecutable en 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
Descargar verify_webhook.js. Solo usa node:crypto integrado. Pasa el cuerpo original como Buffer a receiveWebhook antes de cualquier middleware que analice JSON.
Mostrar ejemplo ejecutable en 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};
Idempotencia y respuestas del receptor
Los ejemplos reciben tu función de cola persistente, enqueue_unique(id, event) / enqueueUnique(id, event). Debe guardar atómicamente el evento autenticado con una restricción única sobre id y devolver false si ya existe. Usa almacenamiento persistente, no un conjunto en memoria. Conserva las claves al menos 48 horas. Los consumidores de la cola deben actuar de forma transaccional o usar la misma clave de idempotencia.
Devuelve 2xx tras guardar correctamente o reconocer un duplicado ya guardado. Devuelve 4xx para firmas inválidas y 5xx para fallos de almacenamiento. No confirmes un evento antes de almacenarlo de forma duradera. PatchFirst no utiliza el contenido de la respuesta.
Entrega y límites
Un proceso independiente envía notificaciones sin retrasar las solicitudes API. Los receptores se atienden en paralelo para que uno lento no retrase a otras flotas. Cualquier 2xx indica entrega correcta. Los errores transitorios de red, los errores TLS (incluida la verificación de certificados) y las respuestas 5xx generan reintentos tras 60, 120, 240 segundos, duplicándose hasta 21600 segundos (6 horas). La ventana de entrega es de 86400 segundos (24 horas) desde la creación del evento; no se programan intentos posteriores. Una respuesta 410 Gone termina la entrega de inmediato; otros 4xx permiten como máximo 3 intentos en total. Una respuesta 3xx, una dirección insegura o una respuesta demasiado grande terminan la entrega sin reintentos. Puede haber duplicados y no se garantiza el orden; la entrega no es un canal garantizado de respuesta a emergencias. Revisa el registro y el receptor cuando se agoten los intentos.
La cola de una flota contiene como máximo 1000 entregas pendientes. Si se llena, se descartan los eventos pendientes más antiguos dejando constancia en el registro. Se permiten 10 guardados de ajustes por minuto y flota.
Nunca se siguen redirecciones. Las direcciones de destino se comprueban tras resolver DNS y en cada conexión: se bloquean las IPv4/IPv6 privadas y reservadas, loopback, enlace local, CGNAT y direcciones de metadatos. También se bloquean las direcciones del propio servicio, incluidos su dominio y sus IP públicos. Al guardar se rechazan localhost y las formas IPv4 numéricas no habituales, octales o hexadecimales. Las direcciones rechazadas solo muestran «no disponible» en el registro. Usa un receptor público; se aplican un tiempo de espera de red de 5 segundos, un máximo de respuesta de 65536 bytes (64 KiB) y límites por flota. Un 3xx es un fallo y nunca se visita su destino.
Notificaciones muestra las entregas recientes con hora, evento y código HTTP de respuesta cuando está disponible. No se conservan los cuerpos de respuesta. Revisa regularmente el panel y el estado del receptor.