From 0a5ccdf6c986e2af3362851c56bc871d96471f26 Mon Sep 17 00:00:00 2001 From: Dan G Date: Thu, 30 Jul 2026 10:53:03 +0200 Subject: [PATCH] feat: add MCP security gate for CI --- .github/workflows/ci.yml | 7 ++ README.md | 50 ++++++++++- examples/ci-safe-mcp.json | 11 +++ lib/ci-gate.ts | 124 ++++++++++++++++++++++++++ lib/collector.ts | 2 +- package.json | 3 +- tests/ci-gate.test.ts | 167 +++++++++++++++++++++++++++++++++++ tests/rendered-html.test.mjs | 1 + tools/collector.ts | 106 +++++++++++++++++++++- 9 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 examples/ci-safe-mcp.json create mode 100644 lib/ci-gate.ts create mode 100644 tests/ci-gate.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e27b445..cda5db4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,13 @@ jobs: - name: Audit production dependencies run: npm audit --omit=dev --audit-level=high + - name: Validate MCP security gate + run: | + npm run audit:ci -- \ + --path ./examples/ci-safe-mcp.json \ + --output ./.ci-mcp-inventory.json \ + --sarif ./.ci-mcp-sentinel.sarif + - name: Generate Kubernetes admission bundle run: | npm run generate:admission -- \ diff --git a/README.md b/README.md index 82f6c0b..035a2c8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ directement applicables. - génération hors ligne de politiques d’admission Kubernetes Sigstore à partir des mêmes identités OCI ; - validation CI déterministe des bundles Kubernetes générés ; +- contrôle CI des configurations avec seuil de sévérité et export SARIF ; - recherche optionnelle des vulnérabilités connues via OSV.dev ; - prise en charge des objets JSON `mcpServers` utilisés par Claude Desktop, Claude Code, Cursor et VS Code, ainsi que des tables TOML `mcp_servers` de @@ -588,6 +589,7 @@ public/ | `npm run build` | Produit et valide la version de production | | `npm run start` | Lance la version construite | | `npm run collect` | Produit un inventaire local assaini | +| `npm run audit:ci -- --path ` | Bloque la CI sur les constats critiques ou élevés | | `npm run collect:sbom` | Produit l’inventaire et le SBOM CycloneDX | | `npm run collect:security` | Ajoute le probe, OSV, la provenance npm et le SBOM | | `npm run collect -- --probe` | Ajoute une négociation passive des endpoints HTTPS | @@ -613,6 +615,52 @@ request et chaque mise à jour de `main`. Le collecteur et le moteur sont testé sur Linux, Windows et macOS. Un second job lance le lint, construit l’application et vérifie le HTML produit. +### Bloquer une configuration MCP à haut risque + +Le mode CI audite uniquement les fichiers passés avec `--path`, exige qu’au +moins un serveur soit trouvé et termine avec le code `3` si un constat critique +ou élevé est détecté : + +```bash +npm run audit:ci -- \ + --path ./.mcp.json \ + --sarif ./mcp-sentinel.sarif +``` + +Le seuil peut être adapté avec `--fail-on critical|high|medium`. Utilisez +`--no-default-paths` dans une CI pour ne jamais auditer les fichiers utilisateur +du runner ; le script `audit:ci` l’active déjà. `--require-servers` empêche un +fichier absent ou mal ciblé de produire un faux succès. Le résumé console +n’affiche ni extraits de configuration ni secrets. + +Exemple GitHub Actions avec publication des constats dans Code Scanning : + +```yaml +permissions: + contents: read + security-events: write + +steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.13 + cache: npm + - run: npm ci + - name: Audit MCP + run: npm run audit:ci -- --path ./.mcp.json --sarif + - name: Publier le rapport SARIF + if: always() && hashFiles('mcp-sentinel.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: mcp-sentinel.sarif +``` + +Les codes de sortie sont stables : `0` pour un contrôle réussi, `1` pour une +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. + ## Limites actuelles - la découverte doit être lancée explicitement sur chaque poste à inventorier ; @@ -644,7 +692,7 @@ l’application et vérifie le HTML produit. ## Prochaines étapes possibles - historique persistant et suivi des écarts dans le temps ; -- intégration CI pour bloquer les configurations à haut risque. +- politiques CI différenciées par environnement ou répertoire. ## Contribution diff --git a/examples/ci-safe-mcp.json b/examples/ci-safe-mcp.json new file mode 100644 index 0000000..3716ece --- /dev/null +++ b/examples/ci-safe-mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "project-tools": { + "command": "node", + "args": [ + "./tools/project-server.mjs", + "--readonly" + ] + } + } +} diff --git a/lib/ci-gate.ts b/lib/ci-gate.ts new file mode 100644 index 0000000..0a5c453 --- /dev/null +++ b/lib/ci-gate.ts @@ -0,0 +1,124 @@ +import type { Finding, McpServer, Severity } from "./audit-engine.ts"; + +const SEVERITY_RANK: Record = { + critical: 3, + high: 2, + medium: 1, +}; + +const SEVERITY_LABEL: Record = { + critical: "critique", + high: "élevé", + medium: "moyen", +}; + +export type SecurityGateOptions = { + threshold?: Severity; + requireServers?: boolean; +}; + +export type BlockingFinding = { + serverId: string; + serverName: string; + finding: Finding; +}; + +export type SecurityGateResult = { + passed: boolean; + threshold?: Severity; + requireServers: boolean; + servers: number; + findings: Record; + blockingFindings: BlockingFinding[]; + missingRequiredServers: boolean; +}; + +export function severityAtOrAbove( + severity: Severity, + threshold: Severity, +): boolean { + return SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold]; +} + +export function evaluateSecurityGate( + servers: McpServer[], + options: SecurityGateOptions, +): SecurityGateResult { + const findings = servers.flatMap((server) => + server.findings.map((finding) => ({ + serverId: server.id, + serverName: server.name, + finding, + })), + ); + const threshold = options.threshold; + const blockingFindings = threshold + ? findings + .filter(({ finding }) => + severityAtOrAbove(finding.severity, threshold), + ) + .sort( + (left, right) => + SEVERITY_RANK[right.finding.severity] - + SEVERITY_RANK[left.finding.severity] || + left.serverName.localeCompare(right.serverName) || + left.finding.rule.localeCompare(right.finding.rule), + ) + : []; + const missingRequiredServers = + Boolean(options.requireServers) && servers.length === 0; + + return { + passed: !missingRequiredServers && blockingFindings.length === 0, + threshold: options.threshold, + requireServers: Boolean(options.requireServers), + servers: servers.length, + findings: { + critical: findings.filter( + ({ finding }) => finding.severity === "critical", + ).length, + high: findings.filter(({ finding }) => finding.severity === "high") + .length, + medium: findings.filter( + ({ finding }) => finding.severity === "medium", + ).length, + }, + blockingFindings, + missingRequiredServers, + }; +} + +export function formatSecurityGateSummary( + result: SecurityGateResult, + maximumDetails = 20, +): string { + const lines = [ + `[MCP Sentinel] Contrôle CI : ${result.passed ? "RÉUSSI" : "ÉCHEC"}.`, + `${result.servers} serveur${result.servers === 1 ? "" : "s"} audité${result.servers === 1 ? "" : "s"} ; ${result.findings.critical} critique${result.findings.critical === 1 ? "" : "s"}, ${result.findings.high} élevé${result.findings.high === 1 ? "" : "s"}, ${result.findings.medium} moyen${result.findings.medium === 1 ? "" : "s"}.`, + ]; + + if (result.threshold) { + lines.push( + `Seuil bloquant : ${SEVERITY_LABEL[result.threshold]} et niveaux supérieurs.`, + ); + } + if (result.missingRequiredServers) { + lines.push( + "Aucun serveur MCP n’a été découvert alors que --require-servers est actif.", + ); + } + + for (const entry of result.blockingFindings.slice(0, maximumDetails)) { + lines.push( + `- [${entry.finding.severity.toUpperCase()}] ${entry.serverName} · ${entry.finding.rule} · ${entry.finding.title}`, + ); + } + const omitted = result.blockingFindings.length - maximumDetails; + if (omitted > 0) { + lines.push( + `- ${omitted} constat${omitted === 1 ? "" : "s"} bloquant${omitted === 1 ? "" : "s"} supplémentaire${omitted === 1 ? "" : "s"} dans le rapport SARIF.`, + ); + } + + return lines.join("\n"); +} diff --git a/lib/collector.ts b/lib/collector.ts index a416190..3ed4bfa 100644 --- a/lib/collector.ts +++ b/lib/collector.ts @@ -1159,7 +1159,7 @@ export async function collectInventory( generatedAt: now().toISOString(), collector: { name: "MCP Sentinel Collector", - version: "1.7.0", + version: "1.8.0", platform: options.platform ?? process.platform, security: { secretsRedacted: true, diff --git a/package.json b/package.json index 506129d..9b025ea 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "build": "vinext build", "start": "vinext start", "collect": "node --experimental-strip-types tools/collector.ts", + "audit:ci": "node --experimental-strip-types tools/collector.ts --no-default-paths --fail-on high --require-servers", "generate:admission": "node --experimental-strip-types tools/admission.ts", "collect:sbom": "node --experimental-strip-types tools/collector.ts --sbom", "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/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/workspaces.test.ts", + "test:unit": "node --experimental-strip-types --test tests/audit-engine.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/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/tests/ci-gate.test.ts b/tests/ci-gate.test.ts new file mode 100644 index 0000000..4e74706 --- /dev/null +++ b/tests/ci-gate.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import type { Finding, McpServer, Severity } from "../lib/audit-engine.ts"; +import { + evaluateSecurityGate, + formatSecurityGateSummary, + severityAtOrAbove, +} from "../lib/ci-gate.ts"; + +function finding(id: string, severity: Severity): Finding { + return { + id, + rule: `MCP-${id}`, + severity, + title: `Constat ${id}`, + description: "Description de test.", + impact: "Impact de test.", + remediation: "Correction de test.", + snippet: '{"secret":"${REDACTED}"}', + }; +} + +function server(name: string, findings: Finding[]): McpServer { + return { + id: name.toLowerCase(), + name, + owner: "Équipe test", + transport: "Stdio", + source: "Fixture", + score: 50, + status: findings.some((entry) => entry.severity === "critical") + ? "critical" + : findings.length + ? "attention" + : "secure", + controls: 10, + findings, + lastScan: "à l’instant", + }; +} + +test("compares severity thresholds inclusively", () => { + assert.equal(severityAtOrAbove("critical", "high"), true); + assert.equal(severityAtOrAbove("high", "high"), true); + assert.equal(severityAtOrAbove("medium", "high"), false); +}); + +test("blocks findings at or above the configured threshold", () => { + const result = evaluateSecurityGate( + [ + server("Serveur B", [finding("003", "medium")]), + server("Serveur A", [ + finding("002", "high"), + finding("001", "critical"), + ]), + ], + { threshold: "high", requireServers: true }, + ); + + assert.equal(result.passed, false); + assert.equal(result.servers, 2); + assert.deepEqual(result.findings, { + critical: 1, + high: 1, + medium: 1, + }); + assert.deepEqual( + result.blockingFindings.map(({ finding: entry }) => entry.severity), + ["critical", "high"], + ); +}); + +test("can require at least one discovered server", () => { + const result = evaluateSecurityGate([], { + threshold: "critical", + requireServers: true, + }); + + assert.equal(result.passed, false); + assert.equal(result.missingRequiredServers, true); + assert.match( + formatSecurityGateSummary(result), + /Aucun serveur MCP n’a été découvert/, + ); +}); + +test("summary excludes configuration snippets and concrete secrets", () => { + const concreteSecret = "must-not-appear-in-ci-logs"; + const unsafeFinding = { + ...finding("004", "critical"), + snippet: `{"token":"${concreteSecret}"}`, + }; + const result = evaluateSecurityGate( + [server("Serveur sensible", [unsafeFinding])], + { threshold: "critical" }, + ); + const summary = formatSecurityGateSummary(result); + + assert.match(summary, /Serveur sensible · MCP-004 · Constat 004/); + assert.doesNotMatch(summary, new RegExp(concreteSecret)); + assert.doesNotMatch(summary, /snippet/); +}); + +test("collector CLI writes SARIF and returns code 3 for a blocked audit", async () => { + const directory = await mkdtemp(join(tmpdir(), "mcp-sentinel-gate-")); + const configuration = join(directory, "unsafe.json"); + const inventory = join(directory, "inventory.json"); + const sarif = join(directory, "results.sarif"); + const concreteSecret = "must-not-appear-in-cli-output"; + + try { + await writeFile( + configuration, + JSON.stringify({ + mcpServers: { + "unsafe-shell": { + command: "powershell.exe", + args: ["-Command", "Write-Output test"], + env: { ACCESS_TOKEN: concreteSecret }, + }, + }, + }), + "utf8", + ); + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + resolve("tools/collector.ts"), + "--no-default-paths", + "--no-lockfiles", + "--path", + configuration, + "--output", + inventory, + "--sarif", + sarif, + "--fail-on", + "high", + "--require-servers", + ], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 3, result.stderr); + assert.match(result.stderr, /Contrôle CI : ÉCHEC/); + assert.match(result.stderr, /MCP-EXEC-01/); + assert.doesNotMatch(result.stderr, new RegExp(concreteSecret)); + + const report = JSON.parse(await readFile(sarif, "utf8")) as { + version: string; + runs: Array<{ results: unknown[] }>; + }; + assert.equal(report.version, "2.1.0"); + assert.ok(report.runs[0].results.length >= 1); + + const storedInventory = await readFile(inventory, "utf8"); + assert.doesNotMatch(storedInventory, new RegExp(concreteSecret)); + assert.match(storedInventory, /\$\{REDACTED\}/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index f69b710..003a6c6 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -122,6 +122,7 @@ test("keeps the audit engine separate from the interface", async () => { assert.match(ciWorkflow, /Generate Kubernetes admission bundle/); assert.match(ciWorkflow, /npm run validate:admission/); assert.match(ciWorkflow, /npm audit --omit=dev --audit-level=high/); + assert.match(ciWorkflow, /npm run audit:ci/); assert.match(layout, /MCP Sentinel — Audit de sécurité MCP/); assert.doesNotMatch(packageJson, /react-loading-skeleton/); let previewFiles = []; diff --git a/tools/collector.ts b/tools/collector.ts index 6ae122c..fc8fc46 100644 --- a/tools/collector.ts +++ b/tools/collector.ts @@ -2,6 +2,15 @@ import { chmod, readFile, stat, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { + auditConfiguration, + createSarifReport, + type Severity, +} from "../lib/audit-engine.ts"; +import { + evaluateSecurityGate, + formatSecurityGateSummary, +} from "../lib/ci-gate.ts"; import { collectInventory } from "../lib/collector.ts"; import { parseOciVerificationPolicyDocument, @@ -14,6 +23,8 @@ import { createCycloneDxReport } from "../lib/supply-chain.ts"; type CliOptions = { additionalPaths: string[]; + failOn?: Severity; + includeDefaultPaths: boolean; lockfilePaths: string[]; output: string; ociBackend?: "cosign" | "github"; @@ -29,7 +40,9 @@ type CliOptions = { provenanceIdentity?: string; provenanceIssuer?: string; provenancePolicy?: ProvenancePolicy; + requireServers: boolean; scanLockfiles: boolean; + sarifOutput?: string; sbomOutput?: string; stdout: boolean; timeoutMs: number; @@ -48,9 +61,11 @@ Usage: npm run collect -- --sbom npm run collect -- --osv --sbom npm run collect -- --path ./mcp.json --output ./mcp-inventory.json + npm run audit:ci -- --path ./.mcp.json --sarif Options: --path Ajoute une configuration JSON ou TOML explicite (répétable) + --no-default-paths Ignore les configurations utilisateur et du workspace --workspace Dossier où rechercher .mcp.json, .vscode/mcp.json et .cursor/mcp.json --lockfile Ajoute un lockfile npm, pnpm, Yarn, uv ou Poetry --no-lockfiles Désactive la découverte des lockfiles du workspace @@ -72,6 +87,9 @@ Options: --oci-policy-file Applique plusieurs politiques OCI par préfixe d’image --sbom [fichier] Produit aussi un SBOM CycloneDX 1.7 + --sarif [fichier] Produit un rapport SARIF (défaut : mcp-sentinel.sarif) + --fail-on Échoue sur critical, high ou medium et niveaux supérieurs + --require-servers Échoue si aucun serveur MCP n’est découvert --timeout Délai du probe, entre 500 et 15000 ms (défaut : 5000) --output Fichier produit (défaut : mcp-inventory.json) --stdout Écrit l’inventaire sur la sortie standard @@ -99,10 +117,12 @@ function readValue(args: string[], index: number, option: string): string { function parseArguments(args: string[]): CliOptions { const options: CliOptions = { additionalPaths: [], + includeDefaultPaths: true, lockfilePaths: [], output: resolve("mcp-inventory.json"), osv: false, probe: false, + requireServers: false, scanLockfiles: true, stdout: false, timeoutMs: 5_000, @@ -124,6 +144,10 @@ function parseArguments(args: string[]): CliOptions { options.scanLockfiles = false; continue; } + if (argument === "--no-default-paths") { + options.includeDefaultPaths = false; + continue; + } if (argument === "--osv") { options.osv = true; continue; @@ -188,6 +212,31 @@ function parseArguments(args: string[]): CliOptions { options.stdout = true; continue; } + if (argument === "--fail-on") { + const threshold = readValue(args, index, argument); + if (!["critical", "high", "medium"].includes(threshold)) { + throw new Error( + "--fail-on doit valoir critical, high ou medium.", + ); + } + options.failOn = threshold as Severity; + index += 1; + continue; + } + if (argument === "--require-servers") { + options.requireServers = true; + continue; + } + if (argument === "--sarif") { + const candidate = args[index + 1]; + if (candidate && !candidate.startsWith("--")) { + options.sarifOutput = resolve(candidate); + index += 1; + } else { + options.sarifOutput = resolve("mcp-sentinel.sarif"); + } + continue; + } if (argument === "--sbom") { const candidate = args[index + 1]; if (candidate && !candidate.startsWith("--")) { @@ -315,6 +364,17 @@ function parseArguments(args: string[]): CliOptions { repository: options.ociRepository, }; } + + const outputs = [ + ...(options.stdout ? [] : [options.output]), + ...(options.sbomOutput ? [options.sbomOutput] : []), + ...(options.sarifOutput ? [options.sarifOutput] : []), + ].map((filePath) => resolve(filePath).toLowerCase()); + if (new Set(outputs).size !== outputs.length) { + throw new Error( + "Les sorties inventaire, SBOM et SARIF doivent utiliser des fichiers distincts.", + ); + } return options; } @@ -344,7 +404,10 @@ async function main() { options.ociPolicyFile, ); } - const inventory = await collectInventory(options); + const inventory = await collectInventory({ + ...options, + ...(options.includeDefaultPaths ? {} : { candidates: [] }), + }); if (options.osv) { const componentCounts = inventory.servers.map( @@ -363,6 +426,10 @@ async function main() { } const serialized = `${JSON.stringify(inventory, null, 2)}\n`; + const auditServers = + options.sarifOutput || options.failOn || options.requireServers + ? auditConfiguration(serialized) + : undefined; if (options.stdout) { process.stdout.write(serialized); @@ -410,6 +477,24 @@ async function main() { } } + if (options.sarifOutput && auditServers) { + await writeFile( + options.sarifOutput, + `${JSON.stringify( + createSarifReport(auditServers, new Date(inventory.generatedAt)), + null, + 2, + )}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); + if (process.platform !== "win32") { + await chmod(options.sarifOutput, 0o600); + } + } + const discovered = inventory.servers.length; const redactions = inventory.servers.reduce( (total, server) => total + server.redactions.length, @@ -449,16 +534,29 @@ async function main() { : "Vérification OCI non demandée.", options.stdout ? "" : `Inventaire : ${options.output}`, options.sbomOutput ? `SBOM : ${options.sbomOutput}` : "", + options.sarifOutput ? `SARIF : ${options.sarifOutput}` : "", ] .filter(Boolean) .join(" "), ); process.stderr.write("\n"); - if ( + const incomplete = inventory.vulnerabilityScan?.status === "error" || inventory.provenanceScan?.status === "error" || - inventory.ociVerification?.status === "error" - ) { + inventory.ociVerification?.status === "error"; + const gate = + auditServers && (options.failOn || options.requireServers) + ? evaluateSecurityGate(auditServers, { + threshold: options.failOn, + requireServers: options.requireServers, + }) + : undefined; + if (gate) { + process.stderr.write(`${formatSecurityGateSummary(gate)}\n`); + } + if (gate && !gate.passed) { + process.exitCode = 3; + } else if (incomplete) { process.exitCode = 2; } }