Commit 25d45fac authored by Kourser's avatar Kourser
Browse files

Veille externe : ce qui se dit dehors

Trois questions, et aucune n'est de l'analyse d'audience : l'incident est-il
public, qui en parle, à quel moment faut-il prendre la parole.

La frontière, décidée avant d'écrire une ligne
  Le flux collecté n'entre pas au journal. Ce sont des écrits de tiers qui ne
  savent pas que nous existons, et un registre en ajout seul ne se purge pas :
  les y verser d'office serait une collecte permanente déguisée en trace de
  crise. Il s'efface tout seul au bout de trente jours.

  Ce qui entre au journal, c'est ce qu'un humain retient — parce qu'alors
  quelqu'un a décidé que cela comptait, et cette décision se relit. La
  publication rejoint la main courante avec sa source, son auteur, son adresse
  et son heure de publication d'origine : « à quelle heure est-ce devenu
  public ? » est une question qui se pose toujours au retour d'expérience.

  C'est la même frontière que les messages directs et les frappes de tableau
  blanc, tirée au même endroit et pour la même raison.

Ce qui est réellement atteignable, dit sans détour
  RSS et Atom d'abord : gratuits, universels, et ce sont eux qui portent la
  presse, les alertes CERT et les sites de fuite. Bluesky, dont la recherche
  publique est encore ouverte. Mastodon, par instance. Et un fournisseur HTTP
  générique pour le reste.

  Il n'y a pas d'adaptateur X ni LinkedIn parce qu'il n'y a pas d'accès gratuit
  à adapter : ces plateformes ont fermé les leurs. L'organisation apporte son
  abonnement, nous décrivons la requête. Promettre le contraire serait vendre
  une fonction qui échoue au premier essai.

Ce qu'on surveille, et ce qu'on refuse de surveiller
  Le nom de l'organisation, ses marques, ses domaines, le nom de l'incident.
  Pas des personnes : surveiller un individu nommé n'est pas de la gestion de
  crise, et l'outil n'a pas à rendre ce geste commode. L'écran le dit à
  l'endroit où l'on saisit les termes, pas dans une note de bas de page.

L'analyseur RSS est écrit à la main
  Quarante lignes qu'on relit valent mieux qu'une dépendance de plus dans un
  produit qui promet de tenir quand tout tombe — et les flux réels sont
  suffisamment mal formés pour qu'aucune bibliothèque ne les prenne tous.

577 → 593 garanties, dont celle qui compte : collecter n'écrit rien au journal
de la crise.

Éprouvé contre une source publique réelle, le flux du CERT-FR : quarante avis
collectés, titres et résumés réunis, balises retirées, adresses et dates
d'origine conservées. Le premier essai avait rendu zéro signal — c'était le
filtre qui fonctionnait, « ransomware » n'apparaissant pas dans un flux rédigé
en français. Puis une publication retenue, retrouvée en main courante avec sa
source et son heure de parution.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 6a531034
Loading
Loading
Loading
Loading
+31 −0
Original line number Diff line number Diff line
@@ -411,6 +411,37 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Veille externe

Ce qui se dit dehors pendant qu'on travaille dedans. Trois questions, et aucune
n'est de l'analyse d'audience : **l'incident est-il public, qui en parle, à quel
moment faut-il prendre la parole.**

**Ce que le flux collecté ne fait pas : entrer au journal.** Ce sont des écrits
de tiers qui ne savent pas que nous existons, et un registre en ajout seul ne se
purge pas — les y verser d'office serait une collecte permanente déguisée en
trace de crise. Il s'efface tout seul au bout de 30 jours. Seul un signal que
quelqu'un **retient** est inscrit, en main courante, avec sa source, son auteur,
son adresse et son heure de publication d'origine : « à quelle heure est-ce
devenu public ? » est une question qui se pose toujours au retour d'expérience.

Quatre genres de sources, choisis sur ce qui est réellement atteignable :

| | |
|---|---|
| **RSS / Atom** | Gratuit, universel. Presse, blogs, alertes CERT, sites de fuite — de loin le plus utile en crise cyber. |
| **Bluesky** | Recherche publique ouverte, sans compte ni abonnement. |
| **Mastodon** | Recherche publique par instance, jeton facultatif. |
| **HTTP générique** | Pour X, LinkedIn ou un agrégateur payant : ces plateformes ont fermé leurs accès gratuits, l'organisation apporte son abonnement. |

Il n'y a **pas** d'adaptateur X ni LinkedIn, parce qu'il n'y a pas d'accès
gratuit à adapter. Promettre le contraire serait vendre une fonction qui échoue
au premier essai.

Les termes surveillés sont ceux de l'organisation — nom, marques, domaines, nom
de l'incident. **Pas des personnes** : surveiller un individu nommé n'est pas de
la gestion de crise, et l'écran le dit.

## Tableau des actions

Un Kanban, mais **pas un second gisement de tâches** : ce sont les actions de
+76 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Veille externe (§6.13)
--
-- Ce qui se dit dehors pendant qu'on travaille dedans. Le besoin n'est pas de
-- l'analyse d'audience : c'est de savoir si l'incident est public, qui en
-- parle, et à quel moment il faut prendre la parole.
--
-- Le flux collecté n'entre PAS au journal. Il est volumineux, il porte des
-- écrits de tiers, et un journal en ajout seul ne se purge pas : y verser
-- automatiquement les propos de gens qui ne savent pas que nous existons
-- serait une collecte permanente déguisée en trace de crise.
--
-- Ce qui entre au journal, c'est ce qu'un humain retient — parce qu'alors
-- quelqu'un a décidé que cela comptait, et cette décision se relit.
-- ═══════════════════════════════════════════════════════════════════════════

create table source_veille (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,
  genre           text        not null check (genre in ('rss','bluesky','mastodon','http')),
  libelle         text        not null,
  -- Adresse du flux, de l'instance, ou du point d'API.
  adresse         text        not null,
  -- Identifiants chiffrés, quand la source en réclame (X, LinkedIn, agrégateur
  -- payant). Même traitement que les passerelles de diffusion : jamais relus.
  identifiants    bytea,
  reglages        jsonb       not null default '{}',

  actif           boolean     not null default true,
  dernier_passage_at timestamptz,
  dernier_echec   text,
  cree_at         timestamptz not null default now(),
  cree_par        uuid        references compte(id) on delete set null
);
create index source_veille_org on source_veille (organisation_id) where actif;

-- ── Ce qu'on surveille ─────────────────────────────────────────────────────
-- Le nom de l'organisation, ses marques, ses domaines, le nom de l'incident.
-- Pas des personnes : surveiller un individu nommé n'est pas de la gestion de
-- crise, et le produit ne doit pas rendre ce geste commode.
create table terme_veille (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,
  terme           text        not null,
  actif           boolean     not null default true,
  cree_at         timestamptz not null default now(),
  unique (organisation_id, terme)
);

-- ── Ce qui a été collecté ──────────────────────────────────────────────────
-- Hors journal, purgeable, avec une rétention courte par défaut.
create table signal_veille (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,
  source_id       uuid        references source_veille(id) on delete set null,
  -- Empreinte de l'adresse d'origine : la même publication vue deux fois n'est
  -- pas deux signaux.
  empreinte       bytea       not null,

  auteur          text,
  texte           text        not null,
  url             text,
  publie_at       timestamptz,
  collecte_at     timestamptz not null default now(),
  termes          text[]      not null default '{}',

  -- Renseigné quand quelqu'un l'a versé au dossier de crise.
  retenu_crise_id uuid        references crise(id) on delete set null,
  retenu_seq      bigint,
  -- Écarté à la main : on ne le remontre plus, sans le supprimer.
  ecarte_at       timestamptz,
  unique (organisation_id, empreinte)
);
create index signal_recent on signal_veille (organisation_id, publie_at desc nulls last);
create index signal_a_lire on signal_veille (organisation_id, collecte_at desc)
  where ecarte_at is null and retenu_seq is null;
+9 −3
Original line number Diff line number Diff line
@@ -32,6 +32,9 @@ export interface TiersEnAttente {
export interface Cockpit {
  crise: {
    id: string;
    /** Le périmètre se déduit de la crise, jamais du sélecteur d'organisation :
     *  un compte multi-organisations verrait sinon la mauvaise. */
    organisation_id: string;
    intitule: string;
    type: string;
    gravite: string;
@@ -66,10 +69,12 @@ const minutesDepuis = (d: Date) => Math.floor((Date.now() - d.getTime()) / 60000

export async function etat(sql: Sql, criseId: string): Promise<Cockpit | null> {
  const [crise] = await sql<{
    id: string; intitule: string; type: string; gravite: string; phase: string;
    id: string; organisation_id: string; intitule: string; type: string;
    gravite: string; phase: string;
    exercice: boolean; declenchee_at: Date; close_at: Date | null;
    seuil_point_situation_minutes: number; preparation_version: number | null;
  }[]>`select id, intitule, type, gravite, phase, exercice, declenchee_at, close_at,
  }[]>`select id, organisation_id, intitule, type, gravite, phase, exercice,
              declenchee_at, close_at,
              seuil_point_situation_minutes, preparation_version
         from crise where id = ${criseId}`;
  if (!crise) return null;
@@ -133,7 +138,8 @@ export async function etat(sql: Sql, criseId: string): Promise<Cockpit | null> {

  return {
    crise: {
      id: crise.id, intitule: crise.intitule, type: crise.type, gravite: crise.gravite,
      id: crise.id, organisation_id: crise.organisation_id,
      intitule: crise.intitule, type: crise.type, gravite: crise.gravite,
      phase: crise.phase, exercice: crise.exercice, declenchee_at: crise.declenchee_at,
      chrono: chrono(crise.declenchee_at, reference),
      seuil_point_situation_minutes: crise.seuil_point_situation_minutes,
+5 −1
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { instantanesPeriodiques } from "./domaine/tableaux.js";
import { relancerEtEscalader } from "./domaine/mobilisation.js";
import { figerLesRetardataires } from "./noyau/instantanes.js";
import { mediaConfigure } from "./adaptateurs/media.js";
import { purger as purgerVeille } from "./veille/veille.js";

/**
 * Entretien périodique.
@@ -44,6 +45,9 @@ export async function passer(
    // EX-15 : sans instantanés, rembobiner une crise longue reviendrait à
    // rejouer un million d'événements à chaque déplacement du curseur.
    const projections = await figerLesRetardataires(sql);
    // §6.13 : une veille qui garde tout devient un fichier. Ce qui a été
    // retenu par quelqu'un survit ; le reste s'efface au bout d'un mois.
    const veille = await purgerVeille(sql);
    if (escalades > 0) log.warn({ escalades }, "mobilisations escaladées vers les suppléants");
    if (enregistrements > 0) {
      log.warn({ enregistrements }, "enregistrements purgés à l'échéance");
@@ -52,7 +56,7 @@ export async function passer(
      log.warn({ acces }, "accès exceptionnels refermés à l'échéance");
    }
    log.debug({ acces, tentatives, sessions, enregistrements, instantanes, relances,
      escalades, projections }, "entretien");
      escalades, projections, veille }, "entretien");
  } catch (e) {
    log.error({ err: e }, "entretien interrompu");
  }
+100 −0
Original line number Diff line number Diff line
import { z } from "zod";
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { Sql } from "../db/client.js";
import type { Env } from "../env.js";
import { requerirMembre, REDACTEURS } from "./garde.js";
import { CATALOGUE_VEILLE } from "../veille/sources.js";
import * as veille from "../veille/veille.js";

/**
 * Veille externe (§6.13).
 *
 * La lecture est ouverte à tous les membres : savoir ce qui se dit dehors
 * intéresse toute la cellule. Configurer les sources est réservé à ceux qui
 * tiennent le dossier de préparation — une source de veille est un accès
 * sortant, avec parfois un abonnement au bout.
 */
export function enregistrerRoutesVeille(app: FastifyInstance, sql: Sql, env: Env): void {
  const lecture = { preHandler: requerirMembre(sql) };
  const ecriture = { preHandler: requerirMembre(sql, REDACTEURS) };
  const org = (req: FastifyRequest) => z.object({ id: z.string().uuid() }).parse(req.params).id;
  const sousId = (req: FastifyRequest) =>
    z.object({ sousId: z.string().uuid() }).parse(req.params).sousId;

  app.get("/organisations/:id/veille/sources", lecture, async (req) => ({
    sources: await veille.listerSources(sql, org(req)),
    catalogue: CATALOGUE_VEILLE,
    termes: await veille.termes(sql, org(req)),
    retention_jours: veille.RETENTION_JOURS,
  }));

  app.post("/organisations/:id/veille/sources", ecriture, async (req, reply) => {
    const c = z.object({
      genre: z.enum(["rss", "bluesky", "mastodon", "http"]),
      libelle: z.string().trim().min(1).max(120),
      adresse: z.string().trim().max(500).default(""),
      identifiants: z.record(z.string()).optional(),
      reglages: z.record(z.string()).optional(),
    }).parse(req.body);
    const id = await veille.ajouterSource(sql, env.SECRET_SESSION, org(req), c,
      req.auth!.compte.id);
    return reply.code(201).send({ id });
  });

  app.delete("/organisations/:id/veille/sources/:sousId", ecriture, async (req, reply) => {
    const fait = await veille.retirerSource(sql, org(req), sousId(req));
    if (!fait) return reply.code(404).send({ code: "introuvable", message: "Source inconnue." });
    return reply.code(204).send();
  });

  app.post("/organisations/:id/veille/termes", ecriture, async (req, reply) => {
    const { terme } = z.object({ terme: z.string().trim().min(2).max(80) }).parse(req.body);
    await veille.ajouterTerme(sql, org(req), terme);
    return reply.code(201).send({ terme });
  });

  app.delete("/organisations/:id/veille/termes/:sousId", ecriture, async (req, reply) => {
    await veille.retirerTerme(sql, org(req), sousId(req));
    return reply.code(204).send();
  });

  /** Un passage à la demande : on ne veut pas attendre le prochain quart d'heure. */
  app.post("/organisations/:id/veille/passage", lecture, async (req) => ({
    releves: await veille.passer(sql, env.SECRET_SESSION, org(req)),
  }));

  app.get("/organisations/:id/veille", lecture, async (req) => {
    const f = z.object({
      tout: z.coerce.boolean().optional(),
      terme: z.string().optional(),
    }).parse(req.query);
    return {
      signaux: await veille.lire(sql, org(req), f),
      volume: await veille.volume(sql, org(req)),
    };
  });

  app.post("/organisations/:id/veille/:sousId/ecart", lecture, async (req, reply) => {
    await veille.ecarter(sql, org(req), sousId(req));
    return reply.code(204).send();
  });

  /**
   * Retenir un signal : le seul geste de cet écran qui touche le journal.
   *
   * À partir de là ce n'est plus de la veille, c'est un élément de la crise.
   */
  app.post("/organisations/:id/veille/:sousId/retenue", lecture, async (req, reply) => {
    const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.body);
    const [c] = await sql<{ n: string }[]>`
      select count(*) as n from crise
       where id = ${criseId} and organisation_id = ${org(req)}`;
    if (Number(c?.n ?? 0) === 0) {
      return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
    }
    const seq = await veille.retenir(sql, org(req), criseId, sousId(req), {
      acteurId: req.auth!.compte.id, sessionId: req.auth!.session.id, origine: "humaine",
    });
    return reply.code(201).send({ seq });
  });
}
Loading