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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- \
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <fichier>` | 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 |
Expand All @@ -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 ;
Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions examples/ci-safe-mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"project-tools": {
"command": "node",
"args": [
"./tools/project-server.mjs",
"--readonly"
]
}
}
}
124 changes: 124 additions & 0 deletions lib/ci-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Finding, McpServer, Severity } from "./audit-engine.ts";

const SEVERITY_RANK: Record<Severity, number> = {
critical: 3,
high: 2,
medium: 1,
};

const SEVERITY_LABEL: Record<Severity, string> = {
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<Severity, number>;
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");
}
2 changes: 1 addition & 1 deletion lib/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading