Commit c54b1c94 authored by Kourser's avatar Kourser
Browse files

Parcours : l'étape Contexte ne demande que les variables du kit

L'étape 3 proposait les six champs de contexte quel que soit le kit : « cours d'eau
local » sur un scénario cyber, « gestionnaire du réseau électrique » sur une
inondation. Les champs utiles se noyaient dans ceux qui ne servaient pas.

Les variables sont maintenant DÉRIVÉES du contenu du kit, côté serveur : un nouveau
`collectTokens` relève les jetons `{{...}}` cités dans le nom, la description, les
objectifs, les personnages, les canaux, les documents et tous les champs d'inject.
Dérivé plutôt que déclaré : une liste tenue à la main mentirait dès la première
retouche d'un kit.

L'étape et le récapitulatif partagent la même liste — récapituler un champ qu'on n'a
jamais proposé n'apporte rien.

Deux replis explicites : un exercice vierge, ou une dérivation qui échoue, proposent
tous les champs, puisque l'auteur écrira ses propres injects. Et une variable citée
par le kit mais absente du formulaire — `ExercisePersonalization` a une forme fixe —
est SIGNALÉE avec l'endroit où la renseigner, plutôt que silencieusement ignorée.
C'est le cas qui se présentera dès qu'on convertira les crochets littéraux des kits
ANSSI en variables.

Cinq cas de test sur `collectTokens`, dont le piège du `lastIndex` d'un regex global
partagé entre deux textes, et la confirmation que `[votre entité]` n'est pas reconnu.

Non compilé ni exécuté (pas de toolchain Node dans l'environnement utilisé).

Co-Authored-By: Claude (RCA)
parent 3c732a46
Loading
Loading
Loading
Loading
+28 −1
Original line number Diff line number Diff line
import { applyVars, buildContext, buildVars } from './personalization';
import { applyVars, buildContext, buildVars, collectTokens } from './personalization';

describe('buildVars', () => {
  it('expose toute clef du contexte comme variable', () => {
@@ -80,3 +80,30 @@ describe('buildContext', () => {
    expect(ctx).toEqual({ organisation: 'Hélios' });
  });
});

describe('collectTokens', () => {
  it('releve les variables citees, dedupliquees et triees', () => {
    expect(
      collectTokens(['Chez {{organisation}}', 'via {{partenaire}} puis {{organisation}}']),
    ).toEqual(['organisation', 'partenaire']);
  });

  it('tolere les espaces dans le jeton', () => {
    expect(collectTokens(['{{ media }}'])).toEqual(['media']);
  });

  it('ignore null, undefined et les textes sans jeton', () => {
    expect(collectTokens([null, undefined, 'aucun jeton ici'])).toEqual([]);
  });

  it('ne se laisse pas piéger par le lastIndex d’un regex global', () => {
    // Un regex global partagé entre deux appels sauterait une occurrence sur deux.
    const texts = ['{{a}}', '{{b}}', '{{c}}', '{{d}}'];
    expect(collectTokens(texts)).toEqual(['a', 'b', 'c', 'd']);
  });

  it('n’accepte pas les crochets littéraux des kits ANSSI', () => {
    // `[votre entité]` n'est pas une variable : c'est un marqueur à convertir.
    expect(collectTokens(['Chez [votre entité]'])).toEqual([]);
  });
});
+20 −0
Original line number Diff line number Diff line
@@ -99,6 +99,26 @@ export function buildVars(

const TOKEN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;

/**
 * Variables `{{...}}` effectivement citees dans un ensemble de textes, triees.
 *
 * Sert a ne demander a l'animateur QUE ce que son kit utilise : proposer « cours
 * d'eau » sur un kit cyber, ou « gestionnaire du reseau » sur un kit inondation,
 * noyait les champs utiles. La liste vient du contenu, elle ne peut donc pas
 * deriver de ce que le kit contient vraiment.
 */
export function collectTokens(texts: (string | null | undefined)[]): string[] {
  const found = new Set<string>();
  for (const text of texts) {
    if (typeof text !== 'string') continue;
    // `matchAll` sur un regex global : on repart d'un lastIndex propre a chaque texte.
    for (const m of text.matchAll(new RegExp(TOKEN.source, 'g'))) {
      found.add(m[1]);
    }
  }
  return [...found].sort((a, b) => a.localeCompare(b));
}

/**
 * Remplace les variables {{cle}} par leur valeur. Un jeton dont la variable
 * n'est pas fournie est laissé intact (utile pour repérer les oublis).
+6 −0
Original line number Diff line number Diff line
@@ -36,6 +36,12 @@ export class KitsController {
    return this.kits.get(me.tenantId, id);
  }

  /** Variables que ce kit utilise : le parcours ne demande que celles-la. */
  @Get(':id/variables')
  variables(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.variables(me.tenantId, id);
  }

  @Post(':id/install')
  @Roles(...EDITORS)
  @HttpCode(HttpStatus.CREATED)
+33 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import {
  applyVars,
  buildContext,
  buildVars,
  collectTokens,
  type Personalization,
} from '../common/personalization';
import { CATALOGUE } from './catalogue';
@@ -26,6 +27,38 @@ export class KitsService {
    private readonly audit: AuditService,
  ) {}

  /**
   * Variables `{{...}}` que ce kit utilise reellement, tous champs confondus.
   *
   * Le parcours de personnalisation s'en sert pour ne demander que le necessaire.
   * On balaye le contenu plutot que de tenir une liste par kit : une liste
   * finirait par mentir des la premiere retouche d'un kit.
   */
  async variables(tenantId: string, id: string): Promise<string[]> {
    const kit = await this.prisma.forTenant(tenantId).kit.findFirst({
      where: { id },
      include: { characters: true, channels: true, documents: true, injects: true },
    });
    if (!kit) throw new NotFoundException('Kit introuvable');

    return collectTokens([
      kit.name,
      kit.description,
      kit.objectives,
      ...kit.characters.flatMap((c) => [c.name, c.title, c.orgUnit]),
      ...kit.channels.map((c) => c.name),
      ...kit.documents.flatMap((d) => [d.name, d.description, d.body]),
      ...kit.injects.flatMap((i) => [
        i.title,
        i.body,
        i.subject,
        i.to,
        i.newsSource,
        i.authorName,
      ]),
    ]);
  }

  /**
   * Importe (une seule fois) les kits fournis dans l'organisation, sous forme de
   * kits editables. Idempotent : ne recree pas un kit deja importe (par builtinId).
+121 −45
Original line number Diff line number Diff line
import { useState, type ReactNode } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import type { ExercisePersonalization, PlayerSkin } from '@cythin/shared';
import { api, ApiError } from './api';
import { useT } from './i18n';
@@ -51,6 +51,43 @@ function Field({
  );
}

/**
 * Champs proposés pour les variables connues du moteur. Une variable citée par un
 * kit mais absente d'ici n'est pas saisissable au parcours : `ExercisePersonalization`
 * a une forme fixe. Elle est alors signalée, pour être renseignée après création
 * depuis la page Variables — plutôt que silencieusement ignorée.
 */
const VARIABLE_FIELDS: Record<
  string,
  { label: string; placeholder?: string; hint?: string }
> = {
  edr: {
    label: 'Antivirus / EDR',
    placeholder: 'Ex. Sécuriscan EDR',
    hint: 'Nom de la solution de sécurité mise en cause.',
  },
  partenaire: { label: 'Partenaire / sous-traitant', placeholder: 'Ex. LogiPro Services' },
  media: { label: 'Média / journaliste', placeholder: 'Ex. Le Quotidien Olympe' },
  cours_deau: {
    label: "Cours d'eau local",
    placeholder: 'Ex. la Vézère',
    hint: 'Défaut : « la rivière ».',
  },
  prefecture: {
    label: 'Autorité préfectorale',
    placeholder: 'Ex. la préfecture de la Dordogne',
    hint: 'Défaut : « la préfecture ».',
  },
  gestionnaire_reseau: {
    label: 'Gestionnaire du réseau électrique',
    placeholder: 'Ex. Enedis',
    hint: 'Défaut : « le gestionnaire du réseau ».',
  },
};

/** Variables déjà collectées à l'étape « Identité » : inutile de les redemander. */
const ASKED_EARLIER = new Set(['organisation', 'ville', 'secteur']);

export function ExerciseWizard({
  mode,
  kitId,
@@ -77,6 +114,42 @@ export function ExerciseWizard({
  const [prefecture, setPrefecture] = useState('');
  const [gestionnaireReseau, setGestionnaireReseau] = useState('');
  const [startAt, setStartAt] = useState('');
  /**
   * Variables citées par le kit. `null` = pas encore connues, ou exercice vierge :
   * dans ce cas on propose tous les champs, l'auteur écrira ses propres injects.
   */
  const [kitVars, setKitVars] = useState<string[] | null>(null);

  useEffect(() => {
    if (mode !== 'kit' || !kitId) return;
    let cancelled = false;
    api
      .kitVariables(kitId)
      .then((v) => {
        if (!cancelled) setKitVars(v);
      })
      .catch(() => {
        // Echec de la derivation : on retombe sur tous les champs plutot que
        // d'empecher la creation.
      });
    return () => {
      cancelled = true;
    };
  }, [mode, kitId]);

  /** Variables de contexte proposées, dans l'ordre, avec leur valeur courante. */
  const contextValues: Record<string, string> = {
    edr,
    partenaire,
    media,
    cours_deau: coursDeau,
    prefecture,
    gestionnaire_reseau: gestionnaireReseau,
  };
  const askedKeys = kitVars
    ? kitVars.filter((k) => k in VARIABLE_FIELDS && !ASKED_EARLIER.has(k))
    : Object.keys(VARIABLE_FIELDS);
  const recapVariables: [string, string][] = askedKeys.map((k) => [k, contextValues[k]]);

  const nameOk = name.trim().length > 0;
  const isLast = step === STEPS.length - 1;
@@ -165,48 +238,45 @@ export function ExerciseWizard({
      </div>
    );
  } else if (step === 2) {
    const bind: Record<string, { value: string; onChange: (v: string) => void }> = {
      edr: { value: edr, onChange: setEdr },
      partenaire: { value: partenaire, onChange: setPartenaire },
      media: { value: media, onChange: setMedia },
      cours_deau: { value: coursDeau, onChange: setCoursDeau },
      prefecture: { value: prefecture, onChange: setPrefecture },
      gestionnaire_reseau: { value: gestionnaireReseau, onChange: setGestionnaireReseau },
    };
    /*
     * Kit connu : on ne demande que ce qu'il cite (askedKeys). Exercice vierge, ou
     * derivation indisponible : on propose tout, l'auteur ecrira ses propres injects.
     */
    const asked = askedKeys;
    // Variables citees par le kit mais non saisissables ici : on les signale.
    const unsupported = (kitVars ?? []).filter(
      (k) => !(k in VARIABLE_FIELDS) && !ASKED_EARLIER.has(k),
    );

    body = (
      <>
        {kitVars && asked.length === 0 && (
          <p className="muted">
            {t('Ce kit ne demande aucune variable de contexte supplémentaire.')}
          </p>
        )}
        <div className="wizard-grid">
          {asked.map((key) => {
            const meta = VARIABLE_FIELDS[key];
            return (
              <Field
          label={t('Antivirus / EDR')}
          value={edr}
          onChange={setEdr}
          placeholder={t('Ex. Sécuriscan EDR')}
          hint={t('Nom de la solution de sécurité mise en cause.')}
        />
        <Field
          label={t('Partenaire / sous-traitant')}
          value={partenaire}
          onChange={setPartenaire}
          placeholder={t('Ex. LogiPro Services')}
        />
        <Field
          label={t('Média / journaliste')}
          value={media}
          onChange={setMedia}
          placeholder={t('Ex. Le Quotidien Olympe')}
        />
        <Field
          label={t("Cours d'eau local")}
          value={coursDeau}
          onChange={setCoursDeau}
          placeholder={t('Ex. la Vézère')}
          hint={t('Kits non-cyber (inondation). Défaut : « la rivière ».')}
        />
        <Field
          label={t('Autorité préfectorale')}
          value={prefecture}
          onChange={setPrefecture}
          placeholder={t('Ex. la préfecture de la Dordogne')}
          hint={t('Kits non-cyber. Défaut : « la préfecture ».')}
        />
        <Field
          label={t('Gestionnaire du réseau électrique')}
          value={gestionnaireReseau}
          onChange={setGestionnaireReseau}
          placeholder={t('Ex. Enedis')}
          hint={t('Kit black-out. Défaut : « le gestionnaire du réseau ».')}
                key={key}
                label={t(meta.label)}
                value={bind[key].value}
                onChange={bind[key].onChange}
                placeholder={meta.placeholder ? t(meta.placeholder) : undefined}
                hint={meta.hint ? t(meta.hint) : undefined}
              />
            );
          })}
          <Field
            label={t('Date-heure de démarrage')}
            value={startAt}
@@ -215,6 +285,13 @@ export function ExerciseWizard({
            hint={t("Informatif (affiché à l'animateur).")}
          />
        </div>
        {unsupported.length > 0 && (
          <p className="muted wizard-hint">
            {t('Ce kit cite aussi :')} <span className="mono">{unsupported.join(', ')}</span>.{' '}
            {t('À renseigner après création, dans « Variables » de l’onglet Préparation.')}
          </p>
        )}
      </>
    );
  } else {
    const rows: [string, string][] = [
@@ -226,12 +303,11 @@ export function ExerciseWizard({
        t("Habillage de l'espace joueur"),
        t(SKINS.find((s) => s.value === playerSkin)?.label ?? ''),
      ],
      [t('Antivirus / EDR'), edr.trim() || ''],
      [t('Partenaire / sous-traitant'), partenaire.trim() || ''],
      [t('Média / journaliste'), media.trim() || ''],
      [t("Cours d'eau local"), coursDeau.trim() || ''],
      [t('Autorité préfectorale'), prefecture.trim() || ''],
      [t('Gestionnaire du réseau électrique'), gestionnaireReseau.trim() || ''],
      // Mêmes variables qu'à l'étape Contexte : récapituler un champ qu'on n'a
      // jamais proposé n'apporte rien.
      ...recapVariables.map(
        ([key, value]): [string, string] => [t(VARIABLE_FIELDS[key].label), value.trim() || ''],
      ),
      [t('Date-heure de démarrage'), startAt ? startAt.replace('T', ' ') : ''],
    ];
    body = (
Loading