diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cda5db4..d8f93a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,20 +68,20 @@ jobs: npm run audit:ci -- \ --path ./examples/ci-safe-mcp.json \ --output ./.ci-mcp-inventory.json \ - --sarif ./.ci-mcp-sentinel.sarif + --sarif ./.ci-secure-mpc.sarif - name: Generate Kubernetes admission bundle run: | npm run generate:admission -- \ --policy-file ./examples/oci-policies.json \ - --namespace mcp-sentinel-ci \ + --namespace secure-mpc-ci \ --output ./.ci-kubernetes-admission - name: Validate Kubernetes admission bundle run: | npm run validate:admission -- \ --policy-file ./examples/oci-policies.json \ - --namespace mcp-sentinel-ci \ + --namespace secure-mpc-ci \ --bundle ./.ci-kubernetes-admission - name: Lint diff --git a/.openai/hosting.json b/.openai/hosting.json index 47c28cb..13bc85d 100644 --- a/.openai/hosting.json +++ b/.openai/hosting.json @@ -1,4 +1,5 @@ { - "d1": null, + "project_id": "appgprj_6a6b16ca69a48191ba9616130aaf4f36", + "d1": "DB", "r2": null } diff --git a/README.md b/README.md index 035a2c8..5bd020b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# MCP Sentinel +# Secure MPC [![CI](https://github.com/mawoole/SecureMPC/actions/workflows/ci.yml/badge.svg)](https://github.com/mawoole/SecureMPC/actions/workflows/ci.yml) -![Aperçu MCP Sentinel](public/og.png) +![Aperçu Secure MPC](public/og.png) -MCP Sentinel est une application web d’audit de configurations MCP +Secure MPC est une application web d’audit de configurations MCP ([Model Context Protocol](https://modelcontextprotocol.io/)). Elle transforme un inventaire de serveurs difficile à relire en une posture de sécurité claire : score global, risques prioritaires, explication de l’impact et correctifs @@ -48,6 +48,7 @@ directement applicables. - score de sécurité global et par serveur ; - priorisation par criticité ; - remédiations expliquées avec extraits de configuration copiables ; +- historique persistant des scores et écarts, comparé audit par audit ; - 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 ; @@ -62,6 +63,10 @@ par un collecteur local explicite : - les valeurs sensibles détectées ne sont jamais affichées ; - aucun secret n’est enregistré dans le stockage du navigateur ; - le registre d’exceptions reste sur l’appareil dans le stockage du navigateur ; +- l’historique distant conserve uniquement des compteurs agrégés par règle, + associés à un identifiant utilisateur pseudonymisé ; +- aucun nom de serveur, chemin, configuration, extrait de correction ou secret + n’est envoyé avec cet historique ; - le rapport PDF est composé et téléchargé localement, sans envoi du contenu ; - les secrets concrets sont remplacés par `${REDACTED}` avant l’écriture de l’inventaire ; @@ -126,7 +131,7 @@ npm run build ## Exceptions de risque Une correction qui ne peut pas être appliquée immédiatement peut être placée -sous exception depuis le détail de l’écart. MCP Sentinel exige : +sous exception depuis le détail de l’écart. Secure MPC exige : - un motif explicite et, idéalement, une référence de suivi ; - un responsable identifié ; @@ -141,6 +146,19 @@ Le registre est conservé uniquement dans le navigateur courant. Le rapport JSON le résultat et ajoute une suppression `external/accepted` documentée pour les seules exceptions actives. +## Historique des audits + +Chaque import, découverte locale ou relance d’audit ajoute un point de posture +dans la base D1 du site. L’historique affiche les 60 points les plus récents et +compare le score, le total d’écarts, les corrections résolues et les nouveaux +constats depuis le point précédent. + +La synchronisation ne transmet que le score, le nombre de serveurs, les +compteurs de sévérité et le nombre de constats par code de règle. L’adresse de +l’utilisateur authentifié sert uniquement à calculer côté serveur une clé +pseudonymisée ; elle n’est pas stockée dans la table. L’utilisateur peut effacer +définitivement son historique depuis cette vue. + ## Rapport PDF Le menu **Exporter > Rapport PDF** produit localement un document paginé prêt à @@ -230,7 +248,7 @@ npm run collect -- --sbom ### Inventaire supply chain et SBOM -MCP Sentinel reconnaît les lanceurs suivants sans les exécuter : +Secure MPC reconnaît les lanceurs suivants sans les exécuter : - npm : `npx`, `npm exec`, `pnpm dlx`, `yarn dlx` et `bunx` ; - Python : `uvx` et `pipx run` ; @@ -299,7 +317,7 @@ et rattachés aux serveurs, directs comme transitifs : npm run collect -- --provenance ``` -Pour chaque composant, MCP Sentinel : +Pour chaque composant, Secure MPC : 1. exige que l’intégrité SRI du lockfile corresponde à `dist.integrity` ; 2. vérifie la signature ECDSA du registre sur @@ -397,7 +415,7 @@ la voie GitHub continue d’utiliser `gh`. Les références d’images peuvent être transmises au registre et au service de confiance choisi. Pour un registre privé, `cosign` ou `gh` peut réutiliser ses -propres identifiants déjà configurés ; MCP Sentinel ne lit ni ne conserve ces +propres identifiants déjà configurés ; Secure MPC ne lit ni ne conserve ces identifiants. ### Admission Kubernetes @@ -535,7 +553,7 @@ exploitable. - **Next.js 16 / React 19** pour l’interface ; - **TypeScript** pour le moteur d’analyse et les composants ; - **vinext / Vite** pour la construction ; -- aucune base de données pour la première version ; +- **Cloudflare D1** pour les synthèses historiques pseudonymisées ; - aucune API distante requise pour l’analyse statique ; OSV reste optionnel. Principaux fichiers : @@ -547,6 +565,7 @@ app/ layout.tsx Métadonnées et partage social 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 collector.ts Découverte, redaction et probe MCP passif lockfiles.ts Graphes package-lock, pnpm, Yarn, uv et Poetry @@ -624,7 +643,7 @@ ou élevé est détecté : ```bash npm run audit:ci -- \ --path ./.mcp.json \ - --sarif ./mcp-sentinel.sarif + --sarif ./secure-mpc.sarif ``` Le seuil peut être adapté avec `--fail-on critical|high|medium`. Utilisez @@ -650,10 +669,10 @@ steps: - name: Audit MCP run: npm run audit:ci -- --path ./.mcp.json --sarif - name: Publier le rapport SARIF - if: always() && hashFiles('mcp-sentinel.sarif') != '' + if: always() && hashFiles('secure-mpc.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: mcp-sentinel.sarif + sarif_file: secure-mpc.sarif ``` Les codes de sortie sont stables : `0` pour un contrôle réussi, `1` pour une @@ -684,15 +703,16 @@ incomplète et que le seuil est aussi dépassé, le refus de politique (`3`) pri écosystèmes ; le statut de l’analyse doit donc être vérifié dans l’inventaire ; - elle ne confirme pas les permissions effectives côté GitHub, base de données, OAuth ou système de fichiers ; -- l’historique affiché est illustratif et n’est pas encore persistant ; +- 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 catalogue de règles devra évoluer avec les spécifications et pratiques MCP. ## Prochaines étapes possibles -- historique persistant et suivi des écarts dans le temps ; - politiques CI différenciées par environnement ou répertoire. +- export CSV de la tendance de posture et des écarts agrégés. ## Contribution diff --git a/app/api/audit-history/route.ts b/app/api/audit-history/route.ts new file mode 100644 index 0000000..8e9d8d4 --- /dev/null +++ b/app/api/audit-history/route.ts @@ -0,0 +1,214 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import { ensureAuditHistorySchema, getDb } from "../../../db"; +import { auditHistory } from "../../../db/schema"; +import { + AuditHistoryValidationError, + AUDIT_HISTORY_LIMIT, + parseAuditHistoryPayload, + type AuditHistoryPayload, + type AuditHistoryRecord, +} from "../../../lib/audit-history"; + +export const dynamic = "force-dynamic"; + +const AUTHENTICATED_EMAIL_HEADER = "oai-authenticated-user-email"; +const MAX_REQUEST_BYTES = 16_384; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { + "Cache-Control": "no-store", + }, + }); +} + +function isLocalRequest(request: Request): boolean { + return ["localhost", "127.0.0.1", "::1"].includes( + new URL(request.url).hostname, + ); +} + +async function actorHash(request: Request): Promise { + const email = request.headers + .get(AUTHENTICATED_EMAIL_HEADER) + ?.trim() + .toLowerCase(); + const identity = email || (isLocalRequest(request) ? "local-preview" : ""); + if (!identity) return null; + + const digest = await crypto.subtle.digest( + "SHA-256", + // Keep the original namespace so existing per-user history remains readable. + new TextEncoder().encode(`mcp-sentinel:audit-history:${identity}`), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +function rowToRecord( + row: typeof auditHistory.$inferSelect, +): AuditHistoryRecord { + const rules = JSON.parse(row.ruleSummary) as unknown; + const payload = parseAuditHistoryPayload({ + source: row.source, + score: row.score, + servers: row.servers, + critical: row.critical, + high: row.high, + medium: row.medium, + toFix: row.toFix, + secure: row.secure, + rules, + }); + return { + id: row.id, + createdAt: new Date(row.createdAt).toISOString(), + ...payload, + }; +} + +async function readPayload(request: Request): Promise { + const contentType = request.headers.get("Content-Type") ?? ""; + const contentLength = Number(request.headers.get("Content-Length") ?? "0"); + if ( + !contentType.toLowerCase().startsWith("application/json") || + !Number.isFinite(contentLength) || + contentLength > MAX_REQUEST_BYTES + ) { + throw new AuditHistoryValidationError("invalid-request"); + } + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) { + throw new AuditHistoryValidationError("invalid-request"); + } + return parseAuditHistoryPayload(JSON.parse(raw) as unknown); +} + +export async function GET(request: Request) { + try { + const owner = await actorHash(request); + if (!owner) return responseJson({ error: "Authentification requise." }, 401); + + await ensureAuditHistorySchema(); + const rows = await getDb() + .select() + .from(auditHistory) + .where(eq(auditHistory.actorHash, owner)) + .orderBy(desc(auditHistory.createdAt)) + .limit(AUDIT_HISTORY_LIMIT); + + return responseJson({ history: rows.map(rowToRecord) }); + } catch { + return responseJson( + { error: "L’historique n’a pas pu être chargé." }, + 500, + ); + } +} + +export async function POST(request: Request) { + try { + if (!sameOrigin(request)) { + return responseJson({ error: "Origine de requête refusée." }, 403); + } + const owner = await actorHash(request); + if (!owner) return responseJson({ error: "Authentification requise." }, 401); + + const payload = await readPayload(request); + const id = crypto.randomUUID(); + const createdAt = Date.now(); + await ensureAuditHistorySchema(); + const db = getDb(); + await db.insert(auditHistory).values({ + id, + actorHash: owner, + createdAt, + source: payload.source, + score: payload.score, + servers: payload.servers, + critical: payload.critical, + high: payload.high, + medium: payload.medium, + toFix: payload.toFix, + secure: payload.secure, + ruleSummary: JSON.stringify(payload.rules), + }); + + const stale = await db + .select({ id: auditHistory.id }) + .from(auditHistory) + .where(eq(auditHistory.actorHash, owner)) + .orderBy(desc(auditHistory.createdAt)) + .limit(500) + .offset(AUDIT_HISTORY_LIMIT); + if (stale.length) { + await db + .delete(auditHistory) + .where( + and( + eq(auditHistory.actorHash, owner), + inArray( + auditHistory.id, + stale.map((entry) => entry.id), + ), + ), + ); + } + + return responseJson( + { + record: { + id, + createdAt: new Date(createdAt).toISOString(), + ...payload, + } satisfies AuditHistoryRecord, + }, + 201, + ); + } catch (error) { + const invalid = + error instanceof SyntaxError || + error instanceof AuditHistoryValidationError; + return responseJson( + { + error: invalid + ? "Le résumé d’audit est invalide." + : "L’audit n’a pas pu être enregistré.", + }, + invalid ? 400 : 500, + ); + } +} + +export async function DELETE(request: Request) { + try { + if (!sameOrigin(request)) { + return responseJson({ error: "Origine de requête refusée." }, 403); + } + const owner = await actorHash(request); + if (!owner) return responseJson({ error: "Authentification requise." }, 401); + + await ensureAuditHistorySchema(); + await getDb() + .delete(auditHistory) + .where(eq(auditHistory.actorHash, owner)); + return responseJson({ deleted: true }); + } catch { + return responseJson( + { error: "L’historique n’a pas pu être supprimé." }, + 500, + ); + } +} diff --git a/app/audit-history-view.tsx b/app/audit-history-view.tsx new file mode 100644 index 0000000..58d2394 --- /dev/null +++ b/app/audit-history-view.tsx @@ -0,0 +1,199 @@ +import { + compareAuditHistory, + type AuditHistoryRecord, + type AuditHistorySource, +} from "../lib/audit-history"; + +type AuditHistoryViewProps = { + history: AuditHistoryRecord[]; + loading: boolean; + error: string; + onClear: () => void; +}; + +const sourceLabel: Record = { + manual: "Audit relancé", + import: "Configuration importée", + discovery: "Inventaire découvert", +}; + +function historyDate(value: string, compact = false): string { + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: compact ? "short" : "long", + ...(compact ? {} : { hour: "2-digit", minute: "2-digit" }), + }).format(new Date(value)); +} + +function signed(value: number, suffix = ""): string { + return `${value > 0 ? "+" : ""}${value}${suffix}`; +} + +export function AuditHistoryView({ + history, + loading, + error, + onClear, +}: AuditHistoryViewProps) { + const trend = [...history].slice(0, 12).reverse(); + const latest = history[0]; + const latestComparison = latest + ? compareAuditHistory(latest, history[1]) + : undefined; + + if (loading) { + return ( +
+ + Chargement de l’historique sécurisé… +

Les synthèses d’audit sont récupérées pour votre espace.

+
+ ); + } + + if (!history.length) { + return ( +
+ + Aucun point de comparaison pour le moment +

+ Importez un inventaire ou lancez un audit. Seuls les scores et + compteurs de règles seront conservés, jamais les configurations. +

+ {error ? {error} : null} +
+ ); + } + + return ( + <> +
+
+ +