diff --git a/README.md b/README.md index dec921d..22e2eb8 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,7 @@ lib/ audit-engine.ts Règles, scoring et exports JSON/SARIF audit-history.ts Agrégation confidentielle et comparaison des audits finding-exceptions.ts Registre local et exports des risques acceptés + trustmap-governance.ts Signature des politiques et bundles d’exceptions chiffrés collector.ts Découverte, redaction et probe MCP passif lockfiles.ts Graphes package-lock, pnpm, Yarn, uv et Poetry kubernetes-admission.ts Génération sûre des politiques d’admission @@ -611,6 +612,7 @@ tests/ pdf-report.test.ts Tests du document PDF et de sa pagination provenance.test.ts Tests ECDSA, digest SLSA et politique Sigstore supply-chain.test.ts Tests npm, PyPI, OCI, PURL et CycloneDX + trustmap-governance.test.ts Tests des signatures et échanges chiffrés workspaces.test.ts Tests de découverte et d’isolation des monorepos rendered-html.test.mjs Tests du rendu de production public/ @@ -720,6 +722,38 @@ 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é. +### Signer les politiques CI + +Dans **TrustMap CI → Chaîne de confiance**, une équipe peut créer une identité +ECDSA P-256, protégée par une phrase secrète et exportée dans un fichier JSON +chiffré. La clé privée n’est jamais écrite en clair ni envoyée à un service. +Cette identité signe ensuite l’ensemble des profils CI et produit un fichier +`.signed.json`. + +Avant de charger les profils, le destinataire doit : + +1. obtenir l’empreinte `sha256:…` du signataire par un canal séparé ; +2. charger le fichier de politique ; +3. saisir cette empreinte comme identité approuvée ; +4. laisser MCP TrustMap vérifier la clé, la signature ECDSA et le schéma. + +Une signature cryptographiquement valide sans empreinte approuvée reste +explicitement **non fiable** et ne modifie pas les profils actifs. Une politique +altérée, un profil mal formé ou une clé substituée est refusé. + +### Échanger les exceptions de manière chiffrée + +Dans **TrustMap Enterprise → Échange confidentiel**, le registre d’exceptions +peut être exporté dans un bundle AES-256-GCM. La clé est dérivée localement +d’une phrase secrète avec PBKDF2-HMAC-SHA-256 et un sel aléatoire. Le fichier +chiffré peut être transmis par le canal documentaire habituel, tandis que la +phrase secrète doit être communiquée séparément. + +À l’import, l’authenticité du bundle et son schéma sont vérifiés avant la +fusion. Les décisions sont rapprochées par identifiant et une révocation gagne +toujours sur une version active, ce qui évite de réactiver un risque déjà +refusé. Aucune phrase secrète n’est persistée par l’application. + ## Limites actuelles - la découverte doit être lancée explicitement sur chaque poste à inventorier ; @@ -745,14 +779,19 @@ serveur, chemin de configuration, extrait ou secret n’est exporté. OAuth ou système de fichiers ; - l’historique est limité à 60 synthèses agrégées et ne permet pas de rouvrir l’inventaire complet d’un audit précédent ; -- le registre d’exceptions est local au navigateur et n’est pas synchronisé - entre les utilisateurs ou les appareils ; +- le registre d’exceptions reste local au navigateur ; sa synchronisation + s’effectue volontairement par échange de bundles chiffrés et n’est pas encore + automatique ; +- l’empreinte d’une identité de signature doit être validée par un canal + distinct ; un fichier auto-signé ne constitue pas à lui seul une identité de + confiance ; - le catalogue de règles devra évoluer avec les spécifications et pratiques MCP. ## Prochaines étapes possibles -- signature et vérification des fichiers de politique CI exportés ; -- synchronisation chiffrée des exceptions entre membres d’un même espace. +- synchronisation automatique des exceptions avec SSO, journal d’accès et + révocation centralisée ; +- clés de signature matérielles ou gérées par un KMS d’entreprise. ## Contribution diff --git a/app/globals.css b/app/globals.css index 701a395..aa29563 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1829,6 +1829,143 @@ button.table-row:hover { margin: -4px 3px 0; } +.policy-signature-card, +.exception-sync-card { + padding: 25px; +} + +.crypto-badge { + background: var(--green-soft); + border: 1px solid #c6e5d6; + border-radius: 999px; + color: var(--green-dark); + flex: 0 0 auto; + font-family: var(--font-geist-mono), monospace; + font-size: 8px; + font-weight: 760; + padding: 6px 9px; +} + +.crypto-intro { + color: var(--muted); + font-size: 10px; + line-height: 1.6; + margin: -7px 0 19px; + max-width: 770px; +} + +.policy-trust-grid, +.exception-sync-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.policy-trust-grid > section { + background: var(--ivory); + border: 1px solid var(--line); + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 13px; + padding: 18px; +} + +.policy-trust-grid label, +.exception-sync-grid > label { + color: var(--muted); + display: flex; + flex-direction: column; + font-size: 9px; + font-weight: 650; + gap: 7px; +} + +.policy-trust-grid input:not([type="file"]), +.exception-sync-grid input:not([type="file"]) { + background: var(--paper); + border: 1px solid var(--line-strong); + border-radius: 9px; + font-size: 10px; + min-height: 40px; + padding: 0 11px; +} + +.file-field { + background: var(--paper); + border: 1px dashed var(--line-strong); + border-radius: 9px; + cursor: pointer; + padding: 11px; +} + +.file-field input { + font-size: 8px; + max-width: 100%; +} + +.file-field > span { + color: var(--subtle); + font-size: 8px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.crypto-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 9px; + margin-top: auto; +} + +.fingerprint { + align-items: center; + background: #eff3ff; + border: 1px solid #d4ddf7; + border-radius: 10px; + display: grid; + gap: 10px; + grid-template-columns: auto minmax(0, 1fr) auto; + margin-top: 14px; + padding: 11px 13px; +} + +.fingerprint span { + color: #49619d; + font-size: 8px; + font-weight: 700; +} + +.fingerprint code { + color: #1d3f9f; + font-family: var(--font-geist-mono), monospace; + font-size: 8px; + overflow-wrap: anywhere; +} + +.crypto-message { + background: var(--green-soft); + border-radius: 8px; + color: var(--green-dark); + font-size: 9px; + line-height: 1.5; + margin: 14px 0 0; + padding: 10px 12px; +} + +.exception-sync-grid { + align-items: end; + grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) auto; +} + +.exception-sync-grid .crypto-actions { + flex-wrap: nowrap; + margin: 0; +} + .view-intro { align-items: flex-end; display: flex; @@ -3561,10 +3698,20 @@ button.table-row:hover { .module-grid, .ci-layout, - .enterprise-layout { + .enterprise-layout, + .policy-trust-grid { grid-template-columns: 1fr; } + .exception-sync-grid { + align-items: stretch; + grid-template-columns: 1fr; + } + + .exception-sync-grid .crypto-actions { + flex-wrap: wrap; + } + .gate-preview { flex-basis: auto; width: 100%; @@ -3750,6 +3897,16 @@ button.table-row:hover { grid-template-columns: 1fr; } + .fingerprint { + align-items: flex-start; + grid-template-columns: 1fr auto; + } + + .fingerprint code { + grid-column: 1 / -1; + grid-row: 2; + } + .module-kpis article { min-height: 94px; } diff --git a/app/page.tsx b/app/page.tsx index 8b577dc..4c9ec68 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1335,6 +1335,7 @@ export default function Home() { servers={servers} exceptions={riskExceptions} onNotify={notify} + onExceptionsImported={setRiskExceptions} /> )} diff --git a/app/trustmap-ci.tsx b/app/trustmap-ci.tsx index e1fd382..f9065a5 100644 --- a/app/trustmap-ci.tsx +++ b/app/trustmap-ci.tsx @@ -10,6 +10,31 @@ import { evaluateCiGate, type TrustMapCiPolicyProfile, } from "../lib/trustmap-modules"; +import { + createPolicySigningIdentity, + signCiPolicyProfiles, + verifySignedCiPolicy, +} from "../lib/trustmap-governance"; + +function downloadJson(filename: string, value: unknown) { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { + type: "application/json;charset=utf-8", + }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +async function readBoundedFile(file: File, maximumBytes = 256 * 1_024) { + if (file.size > maximumBytes) { + throw new Error("Le fichier dépasse la limite de 256 Ko."); + } + return file.text(); +} export function TrustMapCi({ servers, @@ -24,6 +49,17 @@ export function TrustMapCi({ DEFAULT_TRUSTMAP_CI_PROFILES.map((profile) => ({ ...profile })), ); const [activeProfileId, setActiveProfileId] = useState("production"); + const [signerLabel, setSignerLabel] = useState("Équipe sécurité"); + const [signingPassphrase, setSigningPassphrase] = useState(""); + const [identityDocument, setIdentityDocument] = useState(""); + const [identityFilename, setIdentityFilename] = useState(""); + const [policyDocument, setPolicyDocument] = useState(""); + const [policyFilename, setPolicyFilename] = useState(""); + const [expectedKeyId, setExpectedKeyId] = useState(""); + const [observedKeyId, setObservedKeyId] = useState(""); + const [signatureMessage, setSignatureMessage] = useState(""); + const [signatureError, setSignatureError] = useState(""); + const [cryptoBusy, setCryptoBusy] = useState(false); const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0]; const enabledProfiles = useMemo( @@ -83,6 +119,112 @@ export function TrustMapCi({ onNotify(`${label} copié dans le presse-papiers`); }; + const createIdentity = async () => { + setCryptoBusy(true); + setSignatureError(""); + setSignatureMessage(""); + try { + const identity = await createPolicySigningIdentity( + signerLabel, + signingPassphrase, + ); + const serialized = JSON.stringify(identity, null, 2); + setIdentityDocument(serialized); + setIdentityFilename("Identité créée dans cette session"); + setExpectedKeyId(identity.keyId); + setObservedKeyId(identity.keyId); + downloadJson( + `mcp-trustmap-signing-identity-${new Date().toISOString().slice(0, 10)}.json`, + identity, + ); + setSignatureMessage( + "Identité créée et téléchargée. Conservez ce fichier chiffré dans un coffre.", + ); + onNotify("Identité de signature chiffrée créée"); + } catch (error) { + setSignatureError( + error instanceof Error + ? error.message + : "L’identité n’a pas pu être créée.", + ); + } finally { + setCryptoBusy(false); + } + }; + + const signPolicy = async () => { + setCryptoBusy(true); + setSignatureError(""); + setSignatureMessage(""); + try { + if (!identityDocument) { + throw new Error("Créez ou chargez d’abord une identité de signature."); + } + const policy = await signCiPolicyProfiles( + profiles, + identityDocument, + signingPassphrase, + ); + downloadJson( + `mcp-trustmap-ci-policy-${new Date().toISOString().slice(0, 10)}.signed.json`, + policy, + ); + setObservedKeyId(policy.signer.keyId); + setSignatureMessage( + `Politique signée par ${policy.signer.label} et téléchargée.`, + ); + setSigningPassphrase(""); + onNotify("Politique CI signée et exportée"); + } catch (error) { + setSignatureError( + error instanceof Error + ? error.message + : "La politique n’a pas pu être signée.", + ); + } finally { + setCryptoBusy(false); + } + }; + + const verifyPolicy = async () => { + setCryptoBusy(true); + setSignatureError(""); + setSignatureMessage(""); + try { + if (!policyDocument) { + throw new Error("Chargez d’abord un fichier de politique signée."); + } + const result = await verifySignedCiPolicy( + policyDocument, + expectedKeyId, + ); + setObservedKeyId(result.keyId); + if (!result.trusted) { + setSignatureMessage( + "Signature valide, mais identité non approuvée. Comparez puis saisissez l’empreinte attendue avant de charger les profils.", + ); + return; + } + setProfiles(result.profiles.map((profile) => ({ ...profile }))); + setActiveProfileId( + result.profiles.find((profile) => profile.enabled)?.id ?? + result.profiles[0].id, + ); + setSignatureMessage( + `Signature et empreinte validées pour ${result.signerLabel}. Les profils ont été chargés.`, + ); + onNotify("Politique signée vérifiée et chargée"); + } catch (error) { + setSignatureError( + error instanceof Error + ? error.message + : "La politique signée n’a pas pu être vérifiée.", + ); + } finally { + setCryptoBusy(false); + } + }; + return (
@@ -260,6 +402,158 @@ export function TrustMapCi({ Copier + +
+
+
+ CHAÎNE DE CONFIANCE +

Signer et vérifier les politiques CI

+
+ ECDSA P-256 +
+

+ La clé privée est exportée dans une identité chiffrée et ne quitte + jamais votre navigateur en clair. Les destinataires approuvent la + politique en comparant son empreinte par un canal séparé. +

+
+
+ 1 · IDENTITÉ ET SIGNATURE + + + +
+ + +
+
+
+ 2 · VÉRIFICATION ET CHARGEMENT + + + +
+
+ {observedKeyId ? ( +
+ Empreinte observée + {observedKeyId} + +
+ ) : null} + {signatureError ? ( +

+ {signatureError} +

+ ) : null} + {signatureMessage ? ( +

+ {signatureMessage} +

+ ) : null} +
); } diff --git a/app/trustmap-enterprise.tsx b/app/trustmap-enterprise.tsx index 96dc7bb..7dfd34f 100644 --- a/app/trustmap-enterprise.tsx +++ b/app/trustmap-enterprise.tsx @@ -1,44 +1,122 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { McpServer } from "../lib/audit-engine"; import type { RiskException } from "../lib/finding-exceptions"; import { createEnterprisePolicyPack, createEnterpriseSummary, } from "../lib/trustmap-modules"; +import { + createEncryptedRiskExceptionBundle, + decryptRiskExceptionBundle, + mergeRiskExceptions, +} from "../lib/trustmap-governance"; export function TrustMapEnterprise({ servers, exceptions, onNotify, + onExceptionsImported, }: { servers: McpServer[]; exceptions: RiskException[]; onNotify: (message: string) => void; + onExceptionsImported: (exceptions: RiskException[]) => void; }) { + const [passphrase, setPassphrase] = useState(""); + const [encryptedBundle, setEncryptedBundle] = useState(""); + const [encryptedFilename, setEncryptedFilename] = useState(""); + const [syncMessage, setSyncMessage] = useState(""); + const [syncError, setSyncError] = useState(""); + const [syncBusy, setSyncBusy] = useState(false); const summary = useMemo( () => createEnterpriseSummary(servers, exceptions), [exceptions, servers], ); - const exportPolicyPack = () => { - const payload = JSON.stringify( - createEnterprisePolicyPack(servers, exceptions), - null, - 2, - ); + const downloadJson = (filename: string, value: unknown) => { const url = URL.createObjectURL( - new Blob([payload], { type: "application/json" }), + new Blob([JSON.stringify(value, null, 2)], { + type: "application/json;charset=utf-8", + }), ); const link = document.createElement("a"); link.href = url; - link.download = `mcp-trustmap-governance-${new Date().toISOString().slice(0, 10)}.json`; + link.download = filename; link.click(); URL.revokeObjectURL(url); + }; + + const exportPolicyPack = () => { + downloadJson( + `mcp-trustmap-governance-${new Date().toISOString().slice(0, 10)}.json`, + createEnterprisePolicyPack(servers, exceptions), + ); onNotify("Pack de gouvernance Enterprise exporté"); }; + const exportEncryptedExceptions = async () => { + setSyncBusy(true); + setSyncError(""); + setSyncMessage(""); + try { + const bundle = await createEncryptedRiskExceptionBundle( + exceptions, + passphrase, + ); + downloadJson( + `mcp-trustmap-exceptions-${new Date().toISOString().slice(0, 10)}.encrypted.json`, + bundle, + ); + setPassphrase(""); + setSyncMessage( + `${exceptions.length} exception${exceptions.length > 1 ? "s" : ""} exportée${exceptions.length > 1 ? "s" : ""} dans un bundle chiffré.`, + ); + onNotify("Registre d’exceptions chiffré et exporté"); + } catch (error) { + setSyncError( + error instanceof Error + ? error.message + : "Le registre n’a pas pu être chiffré.", + ); + } finally { + setSyncBusy(false); + } + }; + + const importEncryptedExceptions = async () => { + setSyncBusy(true); + setSyncError(""); + setSyncMessage(""); + try { + if (!encryptedBundle) { + throw new Error("Chargez d’abord un bundle d’exceptions chiffré."); + } + const imported = await decryptRiskExceptionBundle( + encryptedBundle, + passphrase, + ); + const merged = mergeRiskExceptions(exceptions, imported); + onExceptionsImported(merged); + setPassphrase(""); + setEncryptedBundle(""); + setEncryptedFilename(""); + setSyncMessage( + `${imported.length} exception${imported.length > 1 ? "s" : ""} vérifiée${imported.length > 1 ? "s" : ""}, ${merged.length} décision${merged.length > 1 ? "s" : ""} dans le registre fusionné.`, + ); + onNotify("Exceptions chiffrées vérifiées et fusionnées"); + } catch (error) { + setSyncError( + error instanceof Error + ? error.message + : "Le bundle d’exceptions n’a pas pu être importé.", + ); + } finally { + setSyncBusy(false); + } + }; + const readiness = [ { label: "Inventaire", @@ -125,9 +203,92 @@ export function TrustMapEnterprise({ +
+
+
+ ÉCHANGE CONFIDENTIEL +

Synchroniser le registre par bundle chiffré

+
+ AES-256-GCM +
+

+ Exportez le registre, transmettez le fichier par votre canal habituel + et communiquez la phrase secrète séparément. L’import fusionne les + décisions par identifiant et propage toujours une révocation. +

+
+ + +
+ + +
+
+ {syncError ? ( +

+ {syncError} +

+ ) : null} + {syncMessage ? ( +

+ {syncMessage} +

+ ) : null} +

- Les fonctions multi-utilisateurs, SSO et synchronisation d’exceptions - nécessitent un service d’identité et ne sont pas simulées dans cette version. + Cet échange de fichiers ne remplace pas une synchronisation automatique + multi-utilisateurs avec SSO, journal d’accès et révocation centralisée.

); diff --git a/lib/trustmap-governance.ts b/lib/trustmap-governance.ts new file mode 100644 index 0000000..2992693 --- /dev/null +++ b/lib/trustmap-governance.ts @@ -0,0 +1,621 @@ +import type { RiskException } from "./finding-exceptions.ts"; +import { parseRiskExceptions } from "./finding-exceptions.ts"; +import { + normalizeTrustMapCiPolicyProfiles, + type TrustMapCiPolicyProfile, +} from "./trustmap-modules.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const PBKDF2_ITERATIONS = 210_000; +const MAX_PASSPHRASE_LENGTH = 256; +const MAX_POLICY_FILE_CHARS = 256 * 1_024; +const MAX_EXCEPTION_FILE_CHARS = 2 * 1_024 * 1_024; +const EXCEPTION_AAD = "mcp-trustmap:risk-exceptions:1.0"; + +type EncryptedPayload = { + algorithm: "AES-256-GCM"; + kdf: { + name: "PBKDF2"; + hash: "SHA-256"; + iterations: number; + salt: string; + }; + iv: string; + ciphertext: string; +}; + +export type PolicySigningIdentityBundle = { + schemaVersion: "1.0"; + kind: "mcp-trustmap-signing-identity"; + createdAt: string; + label: string; + keyId: string; + publicKey: JsonWebKey; + encryptedPrivateKey: EncryptedPayload; +}; + +export type SignedCiPolicyBundle = { + schemaVersion: "1.0"; + kind: "mcp-trustmap-ci-policy"; + issuedAt: string; + profiles: TrustMapCiPolicyProfile[]; + signer: { + label: string; + keyId: string; + algorithm: "ECDSA-P256-SHA256"; + publicKey: JsonWebKey; + }; + signature: string; +}; + +export type EncryptedRiskExceptionBundle = { + schemaVersion: "1.0"; + kind: "mcp-trustmap-risk-exceptions"; + createdAt: string; + exceptionCount: number; + encryption: EncryptedPayload; +}; + +function webCrypto(): Crypto { + if (!globalThis.crypto?.subtle) { + throw new Error("Web Crypto n’est pas disponible dans cet environnement."); + } + return globalThis.crypto; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 16_384) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 16_384)); + } + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +function base64UrlToBytes(value: unknown, maximumBytes: number): Uint8Array { + if ( + typeof value !== "string" || + !/^[A-Za-z0-9_-]+$/u.test(value) || + value.length > Math.ceil((maximumBytes * 4) / 3) + 8 + ) { + throw new Error("Le document cryptographique contient une valeur invalide."); + } + const padded = value.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "=")); + if (binary.length > maximumBytes) { + throw new Error("Le document cryptographique dépasse la limite autorisée."); + } + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function validatePassphrase(passphrase: string): void { + if (passphrase.length < 12 || passphrase.length > MAX_PASSPHRASE_LENGTH) { + throw new Error("La phrase secrète doit contenir entre 12 et 256 caractères."); + } +} + +async function deriveEncryptionKey( + passphrase: string, + salt: Uint8Array, + iterations: number, +): Promise { + validatePassphrase(passphrase); + if (iterations !== PBKDF2_ITERATIONS) { + throw new Error("Les paramètres de dérivation ne sont pas acceptés."); + } + const subtle = webCrypto().subtle; + const material = await subtle.importKey( + "raw", + encoder.encode(passphrase), + "PBKDF2", + false, + ["deriveKey"], + ); + return subtle.deriveKey( + { name: "PBKDF2", hash: "SHA-256", salt, iterations }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +async function encryptValue( + value: unknown, + passphrase: string, + additionalData: string, +): Promise { + const crypto = webCrypto(); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveEncryptionKey(passphrase, salt, PBKDF2_ITERATIONS); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: encoder.encode(additionalData) }, + key, + encoder.encode(canonicalJson(value)), + ); + return { + algorithm: "AES-256-GCM", + kdf: { + name: "PBKDF2", + hash: "SHA-256", + iterations: PBKDF2_ITERATIONS, + salt: bytesToBase64Url(salt), + }, + iv: bytesToBase64Url(iv), + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)), + }; +} + +function validateEncryptedPayload(value: unknown): EncryptedPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Le bloc chiffré est absent ou invalide."); + } + const record = value as Record; + const kdf = record.kdf as Record | undefined; + if ( + record.algorithm !== "AES-256-GCM" || + !kdf || + kdf.name !== "PBKDF2" || + kdf.hash !== "SHA-256" || + kdf.iterations !== PBKDF2_ITERATIONS || + typeof kdf.salt !== "string" || + typeof record.iv !== "string" || + typeof record.ciphertext !== "string" + ) { + throw new Error("Les paramètres cryptographiques ne sont pas acceptés."); + } + return record as EncryptedPayload; +} + +async function decryptValue( + value: unknown, + passphrase: string, + additionalData: string, + maximumPlaintextBytes: number, +): Promise { + const payload = validateEncryptedPayload(value); + const salt = base64UrlToBytes(payload.kdf.salt, 32); + const iv = base64UrlToBytes(payload.iv, 16); + if (salt.length !== 16 || iv.length !== 12) { + throw new Error("Les paramètres cryptographiques sont invalides."); + } + const ciphertext = base64UrlToBytes( + payload.ciphertext, + maximumPlaintextBytes + 32, + ); + const key = await deriveEncryptionKey( + passphrase, + salt, + payload.kdf.iterations, + ); + try { + const plaintext = await webCrypto().subtle.decrypt( + { + name: "AES-GCM", + iv, + additionalData: encoder.encode(additionalData), + }, + key, + ciphertext, + ); + if (plaintext.byteLength > maximumPlaintextBytes) { + throw new Error("Le contenu déchiffré dépasse la limite autorisée."); + } + return JSON.parse(decoder.decode(plaintext)) as unknown; + } catch (error) { + if (error instanceof Error && /limite autorisée/u.test(error.message)) { + throw error; + } + throw new Error( + "Le document ne peut pas être déchiffré : phrase secrète incorrecte ou fichier altéré.", + ); + } +} + +function parseJsonDocument(serialized: string, maximumCharacters: number): unknown { + if (!serialized || serialized.length > maximumCharacters) { + throw new Error("Le fichier est vide ou dépasse la limite autorisée."); + } + try { + return JSON.parse(serialized) as unknown; + } catch { + throw new Error("Le fichier JSON n’est pas valide."); + } +} + +function validatePublicKey(value: unknown): JsonWebKey { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("La clé publique est absente."); + } + const key = value as JsonWebKey; + if ( + key.kty !== "EC" || + key.crv !== "P-256" || + typeof key.x !== "string" || + typeof key.y !== "string" || + key.x.length > 100 || + key.y.length > 100 + ) { + throw new Error("La clé publique de signature est invalide."); + } + return { kty: "EC", crv: "P-256", x: key.x, y: key.y }; +} + +function validatePrivateKey(value: unknown): JsonWebKey { + const publicKey = validatePublicKey(value); + const candidate = value as JsonWebKey; + if (typeof candidate.d !== "string" || candidate.d.length > 100) { + throw new Error("La clé privée de signature est invalide."); + } + return { ...publicKey, d: candidate.d }; +} + +async function keyIdentifier(publicKey: JsonWebKey): Promise { + const digest = await webCrypto().subtle.digest( + "SHA-256", + encoder.encode(canonicalJson(validatePublicKey(publicKey))), + ); + return `sha256:${[...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("")}`; +} + +function validateLabel(value: unknown): string { + if ( + typeof value !== "string" || + value.trim().length < 2 || + value.trim().length > 80 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new Error("Le nom de l’identité de signature est invalide."); + } + return value.trim(); +} + +function validateDate(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.length > 40 || + !Number.isFinite(Date.parse(value)) + ) { + throw new Error(`${label} est invalide.`); + } + return value; +} + +export async function createPolicySigningIdentity( + label: string, + passphrase: string, + createdAt = new Date(), +): Promise { + const normalizedLabel = validateLabel(label); + validatePassphrase(passphrase); + const pair = (await webCrypto().subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const publicKey = validatePublicKey( + await webCrypto().subtle.exportKey("jwk", pair.publicKey), + ); + const privateKey = validatePrivateKey( + await webCrypto().subtle.exportKey("jwk", pair.privateKey), + ); + const keyId = await keyIdentifier(publicKey); + return { + schemaVersion: "1.0", + kind: "mcp-trustmap-signing-identity", + createdAt: createdAt.toISOString(), + label: normalizedLabel, + keyId, + publicKey, + encryptedPrivateKey: await encryptValue( + { privateKey }, + passphrase, + `mcp-trustmap:signing-identity:${keyId}`, + ), + }; +} + +function parseSigningIdentity( + value: unknown, +): Omit & { + encryptedPrivateKey: EncryptedPayload; +} { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Le fichier d’identité de signature est invalide."); + } + const record = value as Record; + if ( + record.schemaVersion !== "1.0" || + record.kind !== "mcp-trustmap-signing-identity" || + typeof record.keyId !== "string" || + !/^sha256:[a-f0-9]{64}$/u.test(record.keyId) + ) { + throw new Error("Le fichier d’identité de signature est incompatible."); + } + return { + schemaVersion: "1.0", + kind: "mcp-trustmap-signing-identity", + createdAt: validateDate(record.createdAt, "La date de création"), + label: validateLabel(record.label), + keyId: record.keyId, + publicKey: validatePublicKey(record.publicKey), + encryptedPrivateKey: validateEncryptedPayload(record.encryptedPrivateKey), + }; +} + +function policyPayload(bundle: Pick< + SignedCiPolicyBundle, + "schemaVersion" | "kind" | "issuedAt" | "profiles" +>) { + return { + schemaVersion: bundle.schemaVersion, + kind: bundle.kind, + issuedAt: bundle.issuedAt, + profiles: bundle.profiles, + }; +} + +export async function signCiPolicyProfiles( + profiles: TrustMapCiPolicyProfile[], + serializedIdentity: string, + passphrase: string, + issuedAt = new Date(), +): Promise { + const identity = parseSigningIdentity( + parseJsonDocument(serializedIdentity, MAX_POLICY_FILE_CHARS), + ); + const actualKeyId = await keyIdentifier(identity.publicKey); + if (actualKeyId !== identity.keyId) { + throw new Error("L’empreinte de l’identité de signature est incohérente."); + } + const privatePayload = await decryptValue( + identity.encryptedPrivateKey, + passphrase, + `mcp-trustmap:signing-identity:${identity.keyId}`, + 4_096, + ); + if ( + !privatePayload || + typeof privatePayload !== "object" || + Array.isArray(privatePayload) + ) { + throw new Error("La clé privée déchiffrée est invalide."); + } + const privateKey = validatePrivateKey( + (privatePayload as Record).privateKey, + ); + if ( + privateKey.x !== identity.publicKey.x || + privateKey.y !== identity.publicKey.y + ) { + throw new Error("La clé privée ne correspond pas à l’identité publique."); + } + const normalizedProfiles = normalizeTrustMapCiPolicyProfiles(profiles); + const unsigned = { + schemaVersion: "1.0" as const, + kind: "mcp-trustmap-ci-policy" as const, + issuedAt: issuedAt.toISOString(), + profiles: normalizedProfiles, + }; + const importedPrivateKey = await webCrypto().subtle.importKey( + "jwk", + privateKey, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ); + const signature = await webCrypto().subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + importedPrivateKey, + encoder.encode(canonicalJson(unsigned)), + ); + return { + ...unsigned, + signer: { + label: identity.label, + keyId: identity.keyId, + algorithm: "ECDSA-P256-SHA256", + publicKey: identity.publicKey, + }, + signature: bytesToBase64Url(new Uint8Array(signature)), + }; +} + +export async function verifySignedCiPolicy( + serializedPolicy: string, + expectedKeyId = "", +): Promise<{ + profiles: TrustMapCiPolicyProfile[]; + signerLabel: string; + keyId: string; + trusted: boolean; +}> { + const value = parseJsonDocument(serializedPolicy, MAX_POLICY_FILE_CHARS); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Le fichier de politique signée est invalide."); + } + const record = value as Record; + const signer = record.signer as Record | undefined; + if ( + record.schemaVersion !== "1.0" || + record.kind !== "mcp-trustmap-ci-policy" || + !signer || + signer.algorithm !== "ECDSA-P256-SHA256" || + typeof signer.keyId !== "string" || + !/^sha256:[a-f0-9]{64}$/u.test(signer.keyId) || + typeof record.signature !== "string" + ) { + throw new Error("Le fichier de politique signée est incompatible."); + } + const publicKey = validatePublicKey(signer.publicKey); + const actualKeyId = await keyIdentifier(publicKey); + if (actualKeyId !== signer.keyId) { + throw new Error("L’empreinte du signataire ne correspond pas à sa clé."); + } + const profiles = normalizeTrustMapCiPolicyProfiles(record.profiles); + const unsigned = { + schemaVersion: "1.0" as const, + kind: "mcp-trustmap-ci-policy" as const, + issuedAt: validateDate(record.issuedAt, "La date de signature"), + profiles, + }; + const importedPublicKey = await webCrypto().subtle.importKey( + "jwk", + publicKey, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + const signature = base64UrlToBytes(record.signature, 256); + const valid = await webCrypto().subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + importedPublicKey, + signature, + encoder.encode(canonicalJson(policyPayload(unsigned))), + ); + if (!valid) { + throw new Error("La signature de la politique n’est pas valide."); + } + const normalizedExpected = expectedKeyId.trim().toLowerCase(); + return { + profiles, + signerLabel: validateLabel(signer.label), + keyId: actualKeyId, + trusted: Boolean(normalizedExpected) && normalizedExpected === actualKeyId, + }; +} + +export async function createEncryptedRiskExceptionBundle( + exceptions: RiskException[], + passphrase: string, + createdAt = new Date(), +): Promise { + const normalized = parseRiskExceptions(JSON.stringify(exceptions)); + if (normalized.length !== exceptions.length) { + throw new Error("Le registre contient une exception invalide."); + } + const payload = { + schemaVersion: "1.0", + kind: "mcp-trustmap-risk-exceptions", + createdAt: createdAt.toISOString(), + exceptions: normalized, + }; + return { + schemaVersion: "1.0", + kind: "mcp-trustmap-risk-exceptions", + createdAt: payload.createdAt, + exceptionCount: normalized.length, + encryption: await encryptValue(payload, passphrase, EXCEPTION_AAD), + }; +} + +export async function decryptRiskExceptionBundle( + serializedBundle: string, + passphrase: string, +): Promise { + const value = parseJsonDocument(serializedBundle, MAX_EXCEPTION_FILE_CHARS); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Le bundle d’exceptions est invalide."); + } + const record = value as Record; + if ( + record.schemaVersion !== "1.0" || + record.kind !== "mcp-trustmap-risk-exceptions" || + typeof record.exceptionCount !== "number" || + !Number.isInteger(record.exceptionCount) || + record.exceptionCount < 0 || + record.exceptionCount > 1_000 + ) { + throw new Error("Le bundle d’exceptions est incompatible."); + } + validateDate(record.createdAt, "La date du bundle"); + const decrypted = await decryptValue( + record.encryption, + passphrase, + EXCEPTION_AAD, + 1_500_000, + ); + if (!decrypted || typeof decrypted !== "object" || Array.isArray(decrypted)) { + throw new Error("Le contenu du bundle d’exceptions est invalide."); + } + const payload = decrypted as Record; + if ( + payload.schemaVersion !== "1.0" || + payload.kind !== "mcp-trustmap-risk-exceptions" || + payload.createdAt !== record.createdAt + ) { + throw new Error("Les métadonnées du bundle d’exceptions sont incohérentes."); + } + const exceptions = parseRiskExceptions(JSON.stringify(payload.exceptions)); + if (exceptions.length !== record.exceptionCount) { + throw new Error("Le nombre d’exceptions du bundle est incohérent."); + } + return exceptions; +} + +function chooseException( + current: RiskException, + incoming: RiskException, +): RiskException { + if (current.revokedAt && incoming.revokedAt) { + return Date.parse(incoming.revokedAt) > Date.parse(current.revokedAt) + ? incoming + : current; + } + if (incoming.revokedAt) return incoming; + if (current.revokedAt) return current; + return Date.parse(incoming.createdAt) > Date.parse(current.createdAt) + ? incoming + : current; +} + +export function mergeRiskExceptions( + current: RiskException[], + incoming: RiskException[], +): RiskException[] { + const normalizedCurrent = parseRiskExceptions(JSON.stringify(current)); + const normalizedIncoming = parseRiskExceptions(JSON.stringify(incoming)); + if ( + normalizedCurrent.length !== current.length || + normalizedIncoming.length !== incoming.length + ) { + throw new Error("Impossible de fusionner un registre d’exceptions invalide."); + } + const merged = new Map(); + for (const exception of normalizedCurrent) merged.set(exception.id, exception); + for (const exception of normalizedIncoming) { + const existing = merged.get(exception.id); + merged.set( + exception.id, + existing ? chooseException(existing, exception) : exception, + ); + } + return [...merged.values()] + .sort( + (left, right) => + Date.parse(right.createdAt) - Date.parse(left.createdAt) || + left.id.localeCompare(right.id), + ) + .slice(0, 1_000); +} diff --git a/lib/trustmap-modules.ts b/lib/trustmap-modules.ts index ce0ff28..00a4f7d 100644 --- a/lib/trustmap-modules.ts +++ b/lib/trustmap-modules.ts @@ -115,6 +115,77 @@ export const DEFAULT_TRUSTMAP_CI_PROFILES: TrustMapCiPolicyProfile[] = [ }, ]; +export function normalizeTrustMapCiPolicyProfiles( + value: unknown, +): TrustMapCiPolicyProfile[] { + if (!Array.isArray(value) || value.length === 0 || value.length > 20) { + throw new Error("Le document doit contenir entre 1 et 20 politiques CI."); + } + + const ids = new Set(); + const profiles = value.map((candidate) => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + throw new Error("Une politique CI est mal formée."); + } + const record = candidate as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const configPath = + typeof record.configPath === "string" ? record.configPath.trim() : ""; + const environment = record.environment; + const failOn = record.failOn; + + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(id) || ids.has(id)) { + throw new Error("Les identifiants de politique CI doivent être uniques et sûrs."); + } + if (!name || name.length > 60 || /[\u0000-\u001f\u007f]/.test(name)) { + throw new Error("Chaque politique CI doit avoir un nom valide."); + } + if ( + environment !== "development" && + environment !== "staging" && + environment !== "production" + ) { + throw new Error("L’environnement de politique CI est invalide."); + } + if (failOn !== "critical" && failOn !== "high" && failOn !== "medium") { + throw new Error("Le seuil de politique CI est invalide."); + } + + const booleanKeys = [ + "enabled", + "sarif", + "sbom", + "osv", + "provenance", + "requireServers", + ] as const; + if (booleanKeys.some((key) => typeof record[key] !== "boolean")) { + throw new Error("Les options de politique CI doivent être booléennes."); + } + + ids.add(id); + const profile: TrustMapCiPolicyProfile = { + id, + name, + environment, + enabled: record.enabled as boolean, + configPath, + failOn, + sarif: record.sarif as boolean, + sbom: record.sbom as boolean, + osv: record.osv as boolean, + provenance: record.provenance as boolean, + requireServers: record.requireServers as boolean, + }; + createCiCommand(profile); + return profile; + }); + + createMultiEnvironmentWorkflow(profiles); + return profiles; +} + function distribution(values: string[]): DistributionItem[] { const counts = new Map(); for (const value of values) { diff --git a/package.json b/package.json index da8251f..84e8900 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "collect:security": "node --experimental-strip-types tools/collector.ts --probe --osv --provenance --sbom", "validate:admission": "node --experimental-strip-types tools/validate-admission.ts", "test": "npm run build && npm run test:unit && npm run test:rendered", - "test:unit": "node --experimental-strip-types --test tests/audit-engine.test.ts tests/audit-history.test.ts tests/ci-gate.test.ts tests/collector.test.ts tests/finding-exceptions.test.ts tests/kubernetes-admission.test.ts tests/kubernetes-admission-validation.test.ts tests/lockfiles.test.ts tests/oci-provenance.test.ts tests/osv.test.ts tests/pdf-report.test.ts tests/provenance.test.ts tests/supply-chain.test.ts tests/trustmap-modules.test.ts tests/workspaces.test.ts", + "test:unit": "node --experimental-strip-types --test tests/audit-engine.test.ts tests/audit-history.test.ts tests/ci-gate.test.ts tests/collector.test.ts tests/finding-exceptions.test.ts tests/kubernetes-admission.test.ts tests/kubernetes-admission-validation.test.ts tests/lockfiles.test.ts tests/oci-provenance.test.ts tests/osv.test.ts tests/pdf-report.test.ts tests/provenance.test.ts tests/supply-chain.test.ts tests/trustmap-governance.test.ts tests/trustmap-modules.test.ts tests/workspaces.test.ts", "test:rendered": "node --test tests/rendered-html.test.mjs", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern .vite", "db:generate": "drizzle-kit generate" diff --git a/public/og.png b/public/og.png index 1245fcd..074ef81 100644 Binary files a/public/og.png and b/public/og.png differ diff --git a/tests/trustmap-governance.test.ts b/tests/trustmap-governance.test.ts new file mode 100644 index 0000000..be0f603 --- /dev/null +++ b/tests/trustmap-governance.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Finding, McpServer } from "../lib/audit-engine.ts"; +import { + createRiskException, + revokeRiskException, +} from "../lib/finding-exceptions.ts"; +import { + createEncryptedRiskExceptionBundle, + createPolicySigningIdentity, + decryptRiskExceptionBundle, + mergeRiskExceptions, + signCiPolicyProfiles, + verifySignedCiPolicy, +} from "../lib/trustmap-governance.ts"; +import { DEFAULT_TRUSTMAP_CI_PROFILES } from "../lib/trustmap-modules.ts"; + +const passphrase = "une phrase secrete de test robuste"; +const now = new Date("2026-07-30T10:00:00.000Z"); + +const finding: Finding = { + id: "remote-transport", + severity: "critical", + title: "Transport non chiffré", + description: "Le serveur utilise HTTP.", + remediation: "Passez le serveur en HTTPS.", + snippet: '"url": "https://mcp.example.test"', + rule: "MCP-NET-01", +}; + +const server: McpServer = { + id: "remote", + name: "Remote MCP", + owner: "Platform", + transport: "HTTP", + source: "Test", + score: 72, + status: "critical", + controls: 10, + findings: [finding], + lastScan: "à l’instant", +}; + +function riskException() { + return createRiskException( + { + id: "exception-1", + server, + finding, + reason: "Migration TLS planifiée et suivie dans SEC-42.", + owner: "Équipe Platform", + expiresAt: "2026-08-15T23:59:59.999Z", + }, + now, + ); +} + +test("signs CI profiles with a protected identity and verifies its fingerprint", async () => { + const identity = await createPolicySigningIdentity( + "Équipe sécurité", + passphrase, + now, + ); + const signed = await signCiPolicyProfiles( + DEFAULT_TRUSTMAP_CI_PROFILES, + JSON.stringify(identity), + passphrase, + now, + ); + const verified = await verifySignedCiPolicy( + JSON.stringify(signed), + identity.keyId, + ); + + assert.equal(verified.trusted, true); + assert.equal(verified.keyId, identity.keyId); + assert.equal(verified.profiles.length, 3); + assert.equal( + ( + await verifySignedCiPolicy( + JSON.stringify(signed), + `sha256:${"0".repeat(64)}`, + ) + ).trusted, + false, + ); + await assert.rejects( + signCiPolicyProfiles( + DEFAULT_TRUSTMAP_CI_PROFILES, + JSON.stringify(identity), + "une autre phrase secrete robuste", + now, + ), + /phrase secrète incorrecte|fichier altéré/u, + ); +}); + +test("rejects a signed CI policy altered after signature", async () => { + const identity = await createPolicySigningIdentity( + "Équipe sécurité", + passphrase, + now, + ); + const signed = await signCiPolicyProfiles( + DEFAULT_TRUSTMAP_CI_PROFILES, + JSON.stringify(identity), + passphrase, + now, + ); + signed.profiles[0].failOn = "medium"; + + await assert.rejects( + verifySignedCiPolicy(JSON.stringify(signed), identity.keyId), + /signature/u, + ); +}); + +test("encrypts and decrypts an exception bundle without plaintext findings", async () => { + const exception = riskException(); + const bundle = await createEncryptedRiskExceptionBundle( + [exception], + passphrase, + now, + ); + const serialized = JSON.stringify(bundle); + + assert.doesNotMatch(serialized, /SEC-42|Remote MCP/u); + assert.deepEqual( + await decryptRiskExceptionBundle(serialized, passphrase), + [exception], + ); + await assert.rejects( + decryptRiskExceptionBundle( + serialized, + "une autre phrase secrete robuste", + ), + /phrase secrète incorrecte|fichier altéré/u, + ); +}); + +test("propagates revocation when encrypted exception registers are merged", () => { + const active = riskException(); + const revoked = revokeRiskException( + active, + new Date("2026-08-01T08:00:00.000Z"), + ); + + assert.deepEqual(mergeRiskExceptions([active], [revoked]), [revoked]); + assert.deepEqual(mergeRiskExceptions([revoked], [active]), [revoked]); +});