Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .openai/hosting.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"d1": null,
"project_id": "appgprj_6a6b16ca69a48191ba9616130aaf4f36",
"d1": "DB",
"r2": null
}
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ;
Expand All @@ -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 ;
Expand Down Expand Up @@ -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é ;
Expand All @@ -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 à
Expand Down Expand Up @@ -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` ;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 :
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
214 changes: 214 additions & 0 deletions app/api/audit-history/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<AuditHistoryPayload> {
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,
);
}
}
Loading