PatchFirst / Documentation technique
Notifications par webhook
La surveillance payante prend en charge une destination HTTPS par parc. PatchFirst envoie les événements sélectionnés par POST et Content-Type: application/json. Les constats complets et les recommandations restent dans votre tableau de bord.
Activer les notifications
- Ouvrez Notifications dans le tableau de bord, sélectionnez un parc avec une surveillance payante active et choisissez Webhook.
- Saisissez une URL HTTPS publique. Le port par défaut est 443 ; précisez explicitement tout autre port. N’incluez ni secrets ni données personnelles dans l’URL.
- Sélectionnez les événements et le nombre d’heures sans inventaire avant de recevoir une notification de serveur silencieux.
- Conservez le secret généré par le service dans le stockage sécurisé de votre destinataire. Il n’est affiché qu’une fois. Sélectionnez Envoyer un test pour vérifier l’intégration.
Après un renouvellement du secret, mettez à jour votre destinataire : l’ancien secret cesse de fonctionner. Modifier les paramètres annule les envois en attente ; une tentative déjà en cours peut toutefois aboutir. Enregistrer des paramètres inchangés conserve les tentatives en attente et l’état des notifications de serveur silencieux. La configuration et l’envoi nécessitent un accès payant actif.
Événements
| Événement | Déclenchement |
|---|---|
finding.new | Nouveau constat P0 ou P1 : première apparition d’un composant sur un serveur ou nouvelle CVE pour celui-ci. |
finding.escalated | Le niveau d’un constat existant augmente jusqu’à P0 ou P1. |
host.kev | Un constat reçoit une CVE qui figure elle-même dans le catalogue KEV. |
host.rating_dropped | La note alphabétique d’un serveur se dégrade par rapport à sa dernière lettre connue, y compris lors des transitions par « ? ». |
host.inventory_stale | Aucun inventaire n’a été reçu depuis un délai supérieur au nombre d’heures configuré. |
webhook.test | Le propriétaire sélectionne Envoyer un test. |
Les événements proviennent des envois d’inventaires ou des recalculs à partir de flux actualisés. Recalculer un état inchangé ne crée pas de nouvel événement. Une nouvelle tentative d’envoi conserve l’identifiant de l’événement.
La clé d’un constat est serveur + composant/paquet. Un événement finding.new liste les principales CVE du constat ; il est envoyé à la première apparition ou lorsqu’une nouvelle CVE affecte le composant. Une modification de version sans nouvelle CVE ou un réordonnancement des principales CVE ne crée pas d’événement. L’ensemble des CVE est suivi, y compris au-delà des trois premières. L’augmentation du niveau d’un constat existant est envoyée sous la forme finding.escalated. Une baisse puis un retour au même niveau sous 24 heures ne renvoie pas de notification pour ce niveau. Un constat qui disparaît puis revient après au moins 24 heures déclenche finding.new. Activer les webhooks ou reprendre les paiements initialise l’historique à partir de l’état actuel sans envoyer de notifications ; un envoi de test explicite est disponible séparément. L’historique continue d’évoluer lorsque les webhooks sont désactivés ou l’accès impayé. Les nouveaux constats et les augmentations de niveau disposent de réglages distincts dans le tableau de bord.
host.kev est envoyé uniquement pour les CVE ayant leur propre indicateur KEV, une fois par paire « serveur, CVE », même si la CVE apparaît dans plusieurs constats. L’indicateur KEV global d’un constat ne s’applique pas à ses autres CVE. Lors d’une transition de note B → ? → F , l’événement contient before: B et after: F : « ? » préserve la dernière lettre connue.
Corps 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"
}
idest l’UUID de l’événement et correspond àX-PatchFirst-Delivery;createdest l’heure de création en secondes Unix UTC entières ;fleetest l’UUID du parc.hostcontient l’UUID du serveur, ou vautnullpour un test.namen’est inclus que pour un alias enregistré en clair. Les noms chiffrés ne sont jamais divulgués ni transmis. Un alias en clair est lu au moment de l’envoi et peut changer entre les tentatives ; la file conserve uniquement l’UUID.summaryest un objet minimal propre à l’événement. Pourfinding.newetfinding.escalated:cves(une liste des trois principales CVE au maximum),level,componentetversion(la version installée). Pourhost.kev:cve(une CVE issue de KEV),level,componentetversion. Lorsque la version installée est inconnue,versionest une chaîne vide. Pour les changements de note :beforeetafter; pour un inventaire absent :hoursetlast_received(secondes Unix UTC) ; pour les tests :messageavec la valeurPatchFirst webhook test. Les destinataires doivent tolérer de nouveaux champs.urlest un lien relatif vers le tableau de bord de ce site ; une connexion est requise. L’inventaire complet, le dictionnaire des noms, les jetons et les données clients en sont exclus.
Signature des requêtes
Vérifiez les quatre en-têtes avant le traitement :
X-PatchFirst-Timestamp: secondes Unix UTC entières de la tentative en cours.X-PatchFirst-Signature:sha256=<hex>, la signature HMAC-SHA256 detimestamp + "." + body. Encodez l’horodatage en ASCII ; utilisez les octets originaux du corps HTTP et le secret exact du tableau de bord en UTF-8. Ne décodez pas le secret en base64.X-PatchFirst-Event: type d’événement, qui doit correspondre àeventdans le JSON authentifié.X-PatchFirst-Delivery: UUID de l’envoi, qui doit correspondre à la valeur signéeid. Il reste stable entre les tentatives.
Refusez les horodatages ayant plus de 300 secondes d’écart avec l’horloge du destinataire et comparez les signatures en temps constant. Synchronisez les horloges. Vérifiez les octets originaux avant de parser le JSON : toute modification de sérialisation, d’espaces ou d’Unicode invalide la signature. Chaque nouvelle tentative a un horodatage et une signature actualisés ; created reste inchangé.
Python version 3
Télécharger verify_webhook.py. Bibliothèque standard uniquement. Transmettez le corps original de la requête sous forme d’octets et les en-têtes à receive_webhook.
Afficher l’exemple Python exécutable
"""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
Télécharger verify_webhook.js. Module intégré node:crypto uniquement. Transmettez le corps original sous forme de Buffer à receiveWebhook avant tout middleware de traitement JSON.
Afficher l’exemple Node.js exécutable
'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};
Idempotence et réponses du destinataire
Les exemples reçoivent votre fonction de file d’attente persistante, enqueue_unique(id, event) / enqueueUnique(id, event). Elle doit enregistrer l’événement authentifié de façon atomique avec une contrainte d’unicité sur l’identifiant, et renvoyer false pour un identifiant déjà présent. Utilisez un stockage persistant plutôt qu’un ensemble en mémoire. Conservez les clés au moins 48 heures. Les consommateurs de la file doivent effectuer les actions de façon transactionnelle ou utiliser la même clé d’idempotence.
Renvoyez 2xx après l’enregistrement réussi ou la reconnaissance d’un doublon déjà enregistré. Renvoyez 4xx pour une signature invalide et 5xx pour un échec d’enregistrement. N’accusez pas réception d’un événement avant son stockage durable. PatchFirst n’utilise pas le contenu de la réponse.
Envoi et limites
Un processus distinct envoie les notifications sans retarder les requêtes API. Les destinataires sont servis en parallèle pour qu’un destinataire lent ne retarde pas les autres parcs. Tout statut 2xx indique un envoi réussi. Les erreurs réseau temporaires, les erreurs TLS (y compris de vérification du certificat) et les réponses 5xx déclenchent de nouvelles tentatives après 60, 120, 240 secondes, puis un doublement jusqu’à 21600 secondes (6 heures). La fenêtre d’envoi est de 86400 secondes (24 heures) après la création de l’événement ; aucune tentative n’est planifiée au-delà. Une réponse 410 Gone arrête immédiatement l’envoi ; les autres réponses 4xx autorisent au maximum 3 tentatives au total. Une réponse 3xx, une adresse non sûre ou une réponse trop volumineuse arrête l’envoi sans nouvelle tentative. Des doublons sont possibles et l’ordre n’est pas garanti ; l’envoi n’est pas un canal d’alerte d’urgence garanti. Consultez le journal et vérifiez votre destinataire après l’épuisement des tentatives.
La file d’un parc contient au maximum 1000 envois en attente. Lorsqu’elle est pleine, les événements en attente les plus anciens sont abandonnés avec une entrée dans le journal. Les paramètres sont limités à 10 enregistrements par minute et par parc.
Les redirections ne sont jamais suivies. Les adresses de destination sont vérifiées après résolution DNS et à chaque connexion : les adresses IPv4/IPv6 privées et réservées, de boucle locale, link-local, CGNAT et de métadonnées sont bloquées. Les adresses du service lui-même, y compris son domaine public et ses IP, sont également bloquées. Localhost et les formes IPv4 numériques, octales ou hexadécimales non standard sont refusés à l’enregistrement. Les adresses refusées apparaissent uniquement comme « indisponibles » dans le journal. Utilisez un destinataire public ; un délai réseau de 5 secondes, une limite de réponse de 65536 octets (64 Kio) et des limites par parc s’appliquent. Une réponse 3xx est un échec et sa destination n’est jamais visitée.
Notifications affiche les envois récents avec la date, l’événement et le code de réponse HTTP lorsqu’il est disponible. Les corps des réponses ne sont pas conservés. Vérifiez régulièrement votre tableau de bord et l’état de votre destinataire.