diff --git a/README.md b/README.md index 59b42a2..dec921d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ directement applicables. - **TrustMap Audit** applique le référentiel de sécurité, priorise les écarts, fournit les correctifs, gère les exceptions et conserve l’historique agrégé. - **TrustMap CI** simule une politique sur l’inventaire courant et génère la - commande ainsi que le workflow GitHub Actions correspondant, avec SARIF, - CycloneDX, OSV et vérification de provenance configurables. + commande ainsi qu’un workflow GitHub Actions multi-environnements, avec + chemins, seuils, SARIF, CycloneDX, OSV et provenance configurables par profil. - **TrustMap Enterprise** mesure la couverture des propriétaires et des preuves, présente la posture par équipe et exporte un pack de gouvernance JSON. @@ -65,6 +65,7 @@ connexion à un annuaire d’entreprise. - priorisation par criticité ; - remédiations expliquées avec extraits de configuration copiables ; - historique persistant des scores et écarts, comparé audit par audit ; +- export CSV chronologique de la posture et des écarts agrégés ; - exceptions de risque motivées, attribuées, datées et révocables ; - export de rapports PDF, JSON, SARIF et d’un SBOM CycloneDX 1.7 ; - vues dédiées aux serveurs, règles et audits ; @@ -696,6 +697,29 @@ erreur d’entrée ou d’exécution, `2` pour une analyse réseau/provenance incomplète et `3` pour une politique CI refusée. Si une analyse distante est incomplète et que le seuil est aussi dépassé, le refus de politique (`3`) prime. +### Politiques par environnement et répertoire + +TrustMap CI propose trois profils indépendants et modifiables : + +| Profil | Chemin initial | Seuil initial | Contrôles réseau | +| --- | --- | --- | --- | +| Développement | `./.mcp/development.json` | critique | désactivés | +| Préproduction | `./.mcp/staging.json` | élevé | OSV et provenance | +| Production | `./.mcp/production.json` | modéré | OSV et provenance | + +Chaque profil peut être inclus ou exclu du workflow. Le générateur produit un +job GitHub Actions séparé par environnement, des noms d’artefacts distincts et +une catégorie SARIF dédiée. Les chemins contenant des caractères de contrôle ou +des opérateurs shell sont refusés avant la génération. + +### Exporter la tendance d’audit + +Dans **TrustMap Audit → Historique & exceptions**, le bouton `Exporter CSV` +produit localement un fichier UTF-8 compatible avec Excel. Les points sont +ordonnés chronologiquement et incluent les variations de score, les écarts +introduits/résolus ainsi que les compteurs agrégés par règle. Aucun nom de +serveur, chemin de configuration, extrait ou secret n’est exporté. + ## Limites actuelles - la découverte doit être lancée explicitement sur chaque poste à inventorier ; @@ -727,8 +751,8 @@ incomplète et que le seuil est aussi dépassé, le refus de politique (`3`) pri ## Prochaines étapes possibles -- politiques CI différenciées par environnement ou répertoire. -- export CSV de la tendance de posture et des écarts agrégés. +- signature et vérification des fichiers de politique CI exportés ; +- synchronisation chiffrée des exceptions entre membres d’un même espace. ## Contribution diff --git a/app/audit-history-view.tsx b/app/audit-history-view.tsx index 58d2394..4b9b6fc 100644 --- a/app/audit-history-view.tsx +++ b/app/audit-history-view.tsx @@ -9,6 +9,7 @@ type AuditHistoryViewProps = { loading: boolean; error: string; onClear: () => void; + onExportCsv: () => void; }; const sourceLabel: Record = { @@ -34,6 +35,7 @@ export function AuditHistoryView({ loading, error, onClear, + onExportCsv, }: AuditHistoryViewProps) { const trend = [...history].slice(0, 12).reverse(); const latest = history[0]; @@ -79,9 +81,17 @@ export function AuditHistoryView({ Synthèses pseudonymisées · configurations exclues - +
+ + +
diff --git a/app/globals.css b/app/globals.css index 89b2f17..701a395 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1487,6 +1487,101 @@ button.table-row:hover { margin-top: 4px; } +.policy-profile-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.policy-profile-grid > article { + align-items: center; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 13px; + display: flex; + min-width: 0; + padding: 10px; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + opacity 160ms ease; +} + +.policy-profile-grid > article.active { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(21, 122, 85, 0.1); +} + +.policy-profile-grid > article.disabled { + opacity: 0.55; +} + +.policy-profile-main { + align-items: center; + background: transparent; + border: 0; + display: flex; + flex: 1; + gap: 11px; + min-width: 0; + padding: 3px; + text-align: left; +} + +.policy-profile-main > span { + align-items: center; + background: var(--green-soft); + border-radius: 9px; + color: var(--green-dark); + display: inline-flex; + flex: 0 0 auto; + font-size: 9px; + font-weight: 780; + height: 34px; + justify-content: center; + width: 34px; +} + +.policy-profile-main > div { + min-width: 0; +} + +.policy-profile-main small { + color: var(--subtle); + display: block; + font-size: 7px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.policy-profile-main strong { + display: block; + font-size: 11px; + margin: 2px 0; +} + +.policy-profile-main p { + color: var(--muted); + font-size: 8px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.policy-profile-grid > article > label { + align-items: center; + color: var(--muted); + display: flex; + flex: 0 0 auto; + font-size: 8px; + gap: 4px; +} + +.policy-profile-grid > article > label input { + accent-color: var(--green); +} + .policy-builder { display: flex; flex-direction: column; @@ -1947,6 +2042,11 @@ button.table-row:hover { gap: 4px; } +.history-actions { + display: flex; + gap: 8px; +} + .history-toolbar small { color: var(--subtle); font-size: 8px; @@ -3646,6 +3746,10 @@ button.table-row:hover { grid-template-columns: 1fr; } + .policy-profile-grid { + grid-template-columns: 1fr; + } + .module-kpis article { min-height: 94px; } @@ -3674,6 +3778,14 @@ button.table-row:hover { .owner-row > span:nth-child(2) { display: none; } + + .history-actions { + width: 100%; + } + + .history-actions .button { + flex: 1; + } } @media (prefers-reduced-motion: reduce) { diff --git a/app/page.tsx b/app/page.tsx index 431cd39..8b577dc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -37,6 +37,7 @@ import { import { AUDIT_HISTORY_LIMIT, createAuditHistoryPayload, + createAuditHistoryCsv, parseAuditHistoryRecords, type AuditHistoryRecord, type AuditHistorySource, @@ -811,6 +812,20 @@ export default function Home() { } }; + const exportAuditHistoryCsv = () => { + const csv = createAuditHistoryCsv(auditHistory); + const downloadUrl = URL.createObjectURL( + new Blob([csv], { type: "text/csv;charset=utf-8" }), + ); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = `mcp-trustmap-history-${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(downloadUrl); + setToast(`${auditHistory.length} points d’audit exportés en CSV`); + window.setTimeout(() => setToast(""), 2600); + }; + const exportReport = async ( format: "json" | "sarif" | "cyclonedx" | "pdf", ) => { @@ -1246,6 +1261,7 @@ export default function Home() { loading={historyLoading} error={historyError} onClear={clearAuditHistory} + onExportCsv={exportAuditHistoryCsv} />
diff --git a/app/trustmap-ci.tsx b/app/trustmap-ci.tsx index 4fbc43f..e1fd382 100644 --- a/app/trustmap-ci.tsx +++ b/app/trustmap-ci.tsx @@ -4,10 +4,11 @@ import { useMemo, useState } from "react"; import type { McpServer, Severity } from "../lib/audit-engine"; import type { RiskException } from "../lib/finding-exceptions"; import { + createMultiEnvironmentWorkflow, createCiCommand, - createGithubActionsWorkflow, + DEFAULT_TRUSTMAP_CI_PROFILES, evaluateCiGate, - type TrustMapCiOptions, + type TrustMapCiPolicyProfile, } from "../lib/trustmap-modules"; export function TrustMapCi({ @@ -19,22 +20,64 @@ export function TrustMapCi({ exceptions: RiskException[]; onNotify: (message: string) => void; }) { - const [options, setOptions] = useState({ - configPath: "./.mcp.json", - failOn: "high", - sarif: true, - sbom: true, - osv: false, - provenance: true, - requireServers: true, - }); - const command = useMemo(() => createCiCommand(options), [options]); - const workflow = useMemo(() => createGithubActionsWorkflow(options), [options]); + const [profiles, setProfiles] = useState(() => + DEFAULT_TRUSTMAP_CI_PROFILES.map((profile) => ({ ...profile })), + ); + const [activeProfileId, setActiveProfileId] = useState("production"); + const activeProfile = + profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0]; + const enabledProfiles = useMemo( + () => profiles.filter((profile) => profile.enabled), + [profiles], + ); + const generated = useMemo(() => { + try { + return { + command: createCiCommand(activeProfile), + workflow: createMultiEnvironmentWorkflow(enabledProfiles), + error: "", + }; + } catch (error) { + return { + command: "", + workflow: "", + error: + error instanceof Error + ? error.message + : "La politique CI n’est pas valide.", + }; + } + }, [activeProfile, enabledProfiles]); + const { command, workflow, error: generationError } = generated; const gate = useMemo( - () => evaluateCiGate(servers, exceptions, options.failOn), - [exceptions, options.failOn, servers], + () => evaluateCiGate(servers, exceptions, activeProfile.failOn), + [activeProfile.failOn, exceptions, servers], ); + const updateActiveProfile = ( + update: Partial, + ) => { + setProfiles((current) => + current.map((profile) => + profile.id === activeProfile.id + ? { ...profile, ...update } + : profile, + ), + ); + }; + + const toggleProfile = (id: string, enabled: boolean) => { + if (!enabled && enabledProfiles.length === 1) { + onNotify("Au moins une politique CI doit rester active"); + return; + } + setProfiles((current) => + current.map((profile) => + profile.id === id ? { ...profile, enabled } : profile, + ), + ); + }; + const copy = async (value: string, label: string) => { await navigator.clipboard.writeText(value); onNotify(`${label} copié dans le presse-papiers`); @@ -47,26 +90,96 @@ export function TrustMapCi({ TRUSTMAP CI

Transformez vos règles MCP en garde-fou de livraison.

- Configurez un seuil, générez un workflow GitHub Actions et publiez - les écarts dans Code Scanning avec un rapport SARIF. + Appliquez des exigences distinctes au développement, à la + préproduction et à la production, puis publiez les écarts dans + Code Scanning.

{gate.passed ? "✓" : "!"} -
SIMULATION SUR L’INVENTAIRE{gate.passed ? "Livraison autorisée" : "Livraison bloquée"}

{gate.label}

+
+ SIMULATION · {activeProfile.name.toUpperCase()} + + {gate.passed ? "Livraison autorisée" : "Livraison bloquée"} + +

{gate.label}

+
+
+ {profiles.map((profile) => ( +
+ + +
+ ))} +
+
-
POLITIQUE

Composer le contrôle

+
+
+ + POLITIQUE · {activeProfile.environment.toUpperCase()} + +

Configurer {activeProfile.name}

+
+
+
+ {generationError ? ( +

+ {generationError} +

+ ) : null}
-
GITHUB ACTIONS

Workflow prêt à versionner

- +
+ + GITHUB ACTIONS · {enabledProfiles.length} POLITIQUE + {enabledProfiles.length > 1 ? "S" : ""} + +

Workflow multi-environnements

+
+
-
{workflow}
+
+            
+              {workflow ||
+                "# Corrigez la politique sélectionnée pour générer le workflow."}
+            
+          
-
COMMANDE ÉQUIVALENTE{command}
- +
+ + COMMANDE · {activeProfile.name.toUpperCase()} + + {command} +
+
); diff --git a/lib/audit-history.ts b/lib/audit-history.ts index 9a72448..87e477c 100644 --- a/lib/audit-history.ts +++ b/lib/audit-history.ts @@ -271,3 +271,64 @@ export function compareAuditHistory( resolvedFindings, }; } + +function csvCell(value: string | number): string { + const text = String(value); + return /[;"\r\n]/.test(text) + ? `"${text.replaceAll('"', '""')}"` + : text; +} + +export function createAuditHistoryCsv( + history: AuditHistoryRecord[], +): string { + const chronological = [...history].reverse(); + const header = [ + "audit_id", + "created_at", + "source", + "score", + "servers", + "secure_servers", + "critical", + "high", + "medium", + "open_findings", + "score_delta", + "finding_delta", + "introduced_findings", + "resolved_findings", + "rules", + ]; + const rows = chronological.map((entry, index) => { + const comparison = compareAuditHistory( + entry, + chronological[index - 1], + ); + const rules = [...entry.rules] + .sort((left, right) => left.rule.localeCompare(right.rule)) + .map((rule) => `${rule.rule}:${rule.severity}:${rule.count}`) + .join("|"); + return [ + entry.id, + entry.createdAt, + entry.source, + entry.score, + entry.servers, + entry.secure, + entry.critical, + entry.high, + entry.medium, + entry.toFix, + comparison.scoreDelta, + comparison.findingDelta, + comparison.introducedFindings, + comparison.resolvedFindings, + rules, + ] + .map(csvCell) + .join(";"); + }); + + return `\uFEFFsep=;\r\n${header.join(";")}\r\n${rows.join("\r\n")}${rows.length ? "\r\n" : ""}`; +} diff --git a/lib/trustmap-modules.ts b/lib/trustmap-modules.ts index 1a70615..ce0ff28 100644 --- a/lib/trustmap-modules.ts +++ b/lib/trustmap-modules.ts @@ -15,6 +15,18 @@ export type TrustMapCiOptions = { requireServers: boolean; }; +export type TrustMapCiEnvironment = + | "development" + | "staging" + | "production"; + +export type TrustMapCiPolicyProfile = TrustMapCiOptions & { + id: string; + name: string; + environment: TrustMapCiEnvironment; + enabled: boolean; +}; + export type DistributionItem = { label: string; count: number; @@ -61,6 +73,48 @@ const severityRank: Record = { medium: 1, }; +export const DEFAULT_TRUSTMAP_CI_PROFILES: TrustMapCiPolicyProfile[] = [ + { + id: "development", + name: "Développement", + environment: "development", + enabled: true, + configPath: "./.mcp/development.json", + failOn: "critical", + sarif: false, + sbom: false, + osv: false, + provenance: false, + requireServers: true, + }, + { + id: "staging", + name: "Préproduction", + environment: "staging", + enabled: true, + configPath: "./.mcp/staging.json", + failOn: "high", + sarif: true, + sbom: true, + osv: true, + provenance: true, + requireServers: true, + }, + { + id: "production", + name: "Production", + environment: "production", + enabled: true, + configPath: "./.mcp/production.json", + failOn: "medium", + sarif: true, + sbom: true, + osv: true, + provenance: true, + requireServers: true, + }, +]; + function distribution(values: string[]): DistributionItem[] { const counts = new Map(); for (const value of values) { @@ -95,11 +149,31 @@ export function createDiscoverSummary(servers: McpServer[]): DiscoverSummary { function shellArgument(value: string): string { const trimmed = value.trim(); + if ( + !trimmed || + trimmed.length > 500 || + /[\u0000-\u001f\u007f$`;&|<>]/.test(trimmed) + ) { + throw new Error("Le chemin de configuration contient des caractères non autorisés."); + } if (/^[A-Za-z0-9_./@:-]+$/.test(trimmed)) return trimmed; - return `"${trimmed.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + return `'${trimmed.replaceAll("'", "'\"'\"'")}'`; } -export function createCiCommand(options: TrustMapCiOptions): string { +function artifactSuffix(value?: string): string { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized ? `-${normalized.slice(0, 48)}` : ""; +} + +function createCiCommandForProfile( + options: TrustMapCiOptions, + profileId?: string, +): string { + const suffix = artifactSuffix(profileId); const args = [ "npm run collect --", "--no-default-paths", @@ -109,11 +183,15 @@ export function createCiCommand(options: TrustMapCiOptions): string { if (options.requireServers) args.push("--require-servers"); if (options.osv) args.push("--osv"); if (options.provenance) args.push("--provenance"); - if (options.sbom) args.push("--sbom ./mcp-trustmap.cdx.json"); - if (options.sarif) args.push("--sarif ./mcp-trustmap.sarif"); + if (options.sbom) args.push(`--sbom ./mcp-trustmap${suffix}.cdx.json`); + if (options.sarif) args.push(`--sarif ./mcp-trustmap${suffix}.sarif`); return args.join(" "); } +export function createCiCommand(options: TrustMapCiOptions): string { + return createCiCommandForProfile(options); +} + export function createGithubActionsWorkflow(options: TrustMapCiOptions): string { const auditCommand = createCiCommand(options); const sarifSteps = options.sarif @@ -153,6 +231,91 @@ jobs: `; } +function jobId(profile: TrustMapCiPolicyProfile): string { + const normalized = profile.id + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, ""); + if (!normalized) { + throw new Error("Chaque politique CI doit avoir un identifiant exploitable."); + } + return `trustmap_${normalized.slice(0, 48)}`; +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +export function createMultiEnvironmentWorkflow( + profiles: TrustMapCiPolicyProfile[], +): string { + const activeProfiles = profiles.filter((profile) => profile.enabled); + if (!activeProfiles.length) { + throw new Error("Activez au moins une politique CI."); + } + + const usedJobs = new Set(); + const jobs = activeProfiles.map((profile) => { + const profileName = profile.name.trim(); + if ( + !profileName || + profileName.length > 60 || + /[\u0000-\u001f\u007f]/.test(profileName) + ) { + throw new Error("Chaque politique CI doit avoir un nom valide."); + } + const id = jobId(profile); + if (usedJobs.has(id)) { + throw new Error("Les identifiants de politique CI doivent être uniques."); + } + usedJobs.add(id); + const command = createCiCommandForProfile(profile, profile.id); + const suffix = artifactSuffix(profile.id); + const sarifStep = profile.sarif + ? ` + - name: Publier les constats SARIF + if: always() && hashFiles('mcp-trustmap${suffix}.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: mcp-trustmap${suffix}.sarif + category: ${yamlString(`mcp-trustmap/${profile.environment}`)}` + : ""; + + return ` ${id}: + name: ${yamlString(`TrustMap · ${profileName}`)} + runs-on: ubuntu-latest + environment: ${yamlString(profile.environment)} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.13 + cache: npm + - run: npm ci --ignore-scripts --no-audit --no-fund + - name: ${yamlString(`Appliquer la politique ${profileName}`)} + run: ${command}${sarifStep}`; + }); + + const securityPermission = activeProfiles.some((profile) => profile.sarif) + ? "\n security-events: write" + : ""; + + return `name: MCP TrustMap policies + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read${securityPermission} + +jobs: +${jobs.join("\n\n")} +`; +} + export function evaluateCiGate( servers: McpServer[], exceptions: RiskException[], diff --git a/public/og.png b/public/og.png index 6519ce3..1245fcd 100644 Binary files a/public/og.png and b/public/og.png differ diff --git a/tests/audit-history.test.ts b/tests/audit-history.test.ts index 5160c09..bceef75 100644 --- a/tests/audit-history.test.ts +++ b/tests/audit-history.test.ts @@ -7,6 +7,7 @@ import type { import { AuditHistoryValidationError, compareAuditHistory, + createAuditHistoryCsv, createAuditHistoryPayload, parseAuditHistoryPayload, parseAuditHistoryRecords, @@ -175,3 +176,27 @@ test("parses bounded API history records", () => { AuditHistoryValidationError, ); }); + +test("exports chronological CSV trends without infrastructure metadata", () => { + const previous = { + ...record("00000000-0000-4000-8000-000000000010", 55, [ + { rule: "MCP-SEC-01", severity: "critical" as const, count: 2 }, + ]), + createdAt: "2026-07-29T10:00:00.000Z", + }; + const current = { + ...record("00000000-0000-4000-8000-000000000011", 75, [ + { rule: "MCP-SEC-01", severity: "critical" as const, count: 1 }, + { rule: "MCP-NET-01", severity: "high" as const, count: 1 }, + ]), + createdAt: "2026-07-30T10:00:00.000Z", + }; + + const csv = createAuditHistoryCsv([current, previous]); + + assert.ok(csv.startsWith("\uFEFFsep=;\r\n")); + assert.match(csv, /audit_id;created_at;source;score/); + assert.ok(csv.indexOf(previous.id) < csv.indexOf(current.id)); + assert.match(csv, /;20;0;1;1;MCP-NET-01:high:1\|MCP-SEC-01:critical:1/); + assert.doesNotMatch(csv, /Équipe confidentielle|Chemin confidentiel/); +}); diff --git a/tests/trustmap-modules.test.ts b/tests/trustmap-modules.test.ts index ec9fcff..0ca61e5 100644 --- a/tests/trustmap-modules.test.ts +++ b/tests/trustmap-modules.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { parse as parseYaml } from "yaml"; import type { McpServer } from "../lib/audit-engine.ts"; import type { RiskException } from "../lib/finding-exceptions.ts"; import { @@ -8,6 +9,8 @@ import { createEnterprisePolicyPack, createEnterpriseSummary, createGithubActionsWorkflow, + createMultiEnvironmentWorkflow, + DEFAULT_TRUSTMAP_CI_PROFILES, evaluateCiGate, type TrustMapCiOptions, } from "../lib/trustmap-modules.ts"; @@ -82,13 +85,21 @@ const ciOptions: TrustMapCiOptions = { test("TrustMap CI génère une commande déterministe et sûre", () => { const command = createCiCommand(ciOptions); - assert.match(command, /--path "\.\/configs\/MCP production\.json"/); + assert.match(command, /--path '\.\/configs\/MCP production\.json'/); assert.match(command, /--fail-on high/); assert.match(command, /--require-servers/); assert.match(command, /--osv/); assert.match(command, /--provenance/); assert.match(command, /mcp-trustmap\.cdx\.json/); assert.match(command, /mcp-trustmap\.sarif/); + assert.throws( + () => + createCiCommand({ + ...ciOptions, + configPath: "./.mcp.json; curl attacker.example", + }), + /caractères non autorisés/, + ); }); test("TrustMap CI produit un workflow GitHub Actions avec SARIF", () => { @@ -99,6 +110,30 @@ test("TrustMap CI produit un workflow GitHub Actions avec SARIF", () => { assert.match(workflow, /npm run collect/); }); +test("TrustMap CI génère des jobs indépendants par environnement", () => { + const workflow = createMultiEnvironmentWorkflow( + DEFAULT_TRUSTMAP_CI_PROFILES, + ); + + assert.match(workflow, /trustmap_development:/); + assert.match(workflow, /trustmap_staging:/); + assert.match(workflow, /trustmap_production:/); + assert.match(workflow, /environment: "production"/); + assert.match(workflow, /--fail-on critical/); + assert.match(workflow, /--fail-on high/); + assert.match(workflow, /--fail-on medium/); + assert.match(workflow, /mcp-trustmap-production\.sarif/); + assert.match(workflow, /category: "mcp-trustmap\/production"/); + assert.equal( + (workflow.match(/uses: actions\/checkout@v6/g) ?? []).length, + 3, + ); + const parsed = parseYaml(workflow) as { + jobs: Record; + }; + assert.equal(parsed.jobs.trustmap_production.environment, "production"); +}); + test("TrustMap CI simule les constats ouverts et les exceptions actives", () => { const blocked = evaluateCiGate(servers, [], "high"); assert.equal(blocked.passed, false);