diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4992164 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_SOC2_SYNC_MODE=local +VITE_SOC2_API_BASE_URL=http://localhost:4177/api diff --git a/README.md b/README.md index 6e5612d..ea95995 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,130 @@ # Chezlepro SOC 2 Type II Cockpit -Cockpit web React orienté **conformité opérationnelle SOC 2 Type II** pour Chezlepro Inc. +Cockpit web React + API Node pour piloter une conformité opérationnelle **SOC 2 Type II**. -L’application fournit : +Cette version n’est plus seulement un prototype d’interface. Le dépôt contient maintenant : -- un **radar des 5 piliers** SOC 2 ; -- un suivi **pondéré des jalons** ; -- une **bibliothèque de preuves** éditable ; -- une **salle auditeur** avec paquets de revue ; -- une **source de vérité JSON** persistée localement ; -- des **exports/manifests** pour préparer un dépôt Git versionné. +- un **frontend React/Vite** ; +- un **backend HTTP Node** ; +- un **registre JSON versionné** dans `compliance/soc2/registry/` ; +- des **manifests dérivés** ; +- un répertoire d’**artefacts de preuves** ; +- un **contrat de connecteurs** pour Forgejo / Icinga2 / Keycloak. -## Architecture +## Structure ```text src/ App.jsx - styles.css model/ - schema.js - seed.js - stages.js lib/ - utils.js - validation.js - selectors.js - view.js - features/ - hooks/ - useSoc2Registry.js + features/hooks/ components/ - PillarOverviewCard.jsx - MilestoneCard.jsx - EvidenceLibrary.jsx - EvidenceEditor.jsx - DataTab.jsx - ui/ - primitives.jsx +server/ + index.js + config.js + lib/ + http.js + store.js + contracts/ + connectors.json + scripts/ + seed-registry.mjs +compliance/soc2/ + registry/ + soc2-registry.json + evidence-manifest.json + auditor-pack-index.json + evidence/ + packs/ ``` -## Principes de conception +## Modes de fonctionnement -- **Modèle** : constantes, schéma, seed data, états des contrôles. -- **Lib métier** : normalisation, validation, calculs, sélecteurs. -- **Hook de registre** : persistance locale, import/export, mutations synchronisées. -- **Composants** : interface séparée du moteur métier. +### Mode local -Le cockpit est prêt à évoluer vers une vraie source de vérité Git/Forgejo : - -- chemin recommandé pour le registre : `compliance/soc2/registry/soc2-registry.json` -- pièces jointes recommandées : `compliance/soc2/evidence/` - -## Lancer le projet +Le frontend persiste le registre dans `localStorage`. ```bash +cp .env.example .env +# laisser VITE_SOC2_SYNC_MODE=local npm install npm run dev ``` -Build de production : +### Mode API + +Le frontend devient client d’une source de vérité sur disque, servie par l’API locale. ```bash -npm run build -npm run preview +cp .env.example .env +# définir VITE_SOC2_SYNC_MODE=api +npm install +npm run seed:registry +npm run server +npm run dev ``` -## Dépendances +API par défaut : `http://localhost:4177/api` -- React -- Vite -- Recharts -- Framer Motion -- Lucide React +## Endpoints backend -## Source de vérité +- `GET /api/health` +- `GET /api/registry` +- `PUT /api/registry` +- `POST /api/registry/reset` +- `GET /api/evidence` +- `POST /api/evidence` -Le cockpit persiste son état dans `localStorage` pour le prototype. +### Exemple : écrire le registre -La prochaine étape naturelle est de remplacer cette persistance par : +```bash +curl -X PUT http://localhost:4177/api/registry \ + -H 'Content-Type: application/json' \ + --data @compliance/soc2/registry/soc2-registry.json +``` -1. un fichier JSON versionné dans un dépôt Forgejo ; -2. une API légère de lecture/écriture ; -3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes. +### Exemple : pousser un artefact de preuve -## Limites actuelles +```bash +python - <<'PY' +import base64, json +from pathlib import Path +payload = { + "filename": "example-proof.txt", + "contentBase64": base64.b64encode(b"preuve de test").decode(), + "mimeType": "text/plain" +} +Path("payload.json").write_text(json.dumps(payload), encoding="utf-8") +PY -- pas d’authentification applicative ; -- pas de backend ; -- pas de gestion de fichiers binaires de preuves ; -- pas encore de synchronisation distante. +curl -X POST http://localhost:4177/api/evidence \ + -H 'Content-Type: application/json' \ + --data @payload.json +``` -## Recommandation d’évolution +## Connecteurs prévus -Séquence recommandée : +Le fichier `server/contracts/connectors.json` décrit les contrats attendus pour : -1. brancher le registre JSON versionné ; -2. ajouter gestion de pièces jointes ; -3. ajouter règles de validation plus strictes ; -4. ajouter API backend ; -5. brancher les collecteurs automatiques de preuves. +- **Forgejo** : changements, PR, approbations +- **Icinga2** : disponibilité, incidents, tendances +- **Keycloak** : MFA, rôles admin, revues d’accès + +Le dépôt n’effectue pas encore les collectes réelles sans les paramètres d’accès du site cible, mais l’architecture est prête pour les implémenter. + +## Ce qui rend cette version utile + +- le registre n’est plus seulement stocké côté navigateur ; +- l’API peut servir de **source de vérité locale** ; +- les manifests sont régénérés à chaque écriture ; +- les artefacts de preuves ont un emplacement physique ; +- le front peut fonctionner en **mode local** ou **mode API**. + +## Séquence suivante recommandée + +1. brancher les vrais accès Forgejo / Icinga2 / Keycloak ; +2. ajouter une authentification applicative ; +3. gérer le téléversement multipart réel ; +4. ajouter des jobs de collecte planifiés ; +5. produire des packs auditeur exportables en HTML/PDF. diff --git a/package.json b/package.json index f1d1a90..d5494fb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "server": "node server/index.js", + "seed:registry": "node server/scripts/seed-registry.mjs", + "dev:full": "sh -c \"node server/index.js & vite\"" }, "dependencies": { "framer-motion": "^12.23.12", diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..a159960 --- /dev/null +++ b/server/config.js @@ -0,0 +1,12 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const repoRoot = path.resolve(__dirname, ".."); +export const host = process.env.SOC2_HOST || "127.0.0.1"; +export const port = Number(process.env.SOC2_PORT || 4177); +export const registryPath = path.join(repoRoot, "compliance", "soc2", "registry", "soc2-registry.json"); +export const manifestsDir = path.join(repoRoot, "compliance", "soc2", "registry"); +export const evidenceDir = path.join(repoRoot, "compliance", "soc2", "evidence"); diff --git a/server/contracts/connectors.json b/server/contracts/connectors.json new file mode 100644 index 0000000..f0a2d85 --- /dev/null +++ b/server/contracts/connectors.json @@ -0,0 +1,34 @@ +{ + "connectors": [ + { + "name": "forgejo", + "purpose": "Collecter les changements, pull requests, approbations et tags de release.", + "input": { + "baseUrl": "https://forge.example.tld", + "tokenEnv": "FORGEJO_TOKEN", + "repositories": ["org/repo"] + }, + "producesEvidence": ["change-logs", "pull-request-approvals", "release-history"] + }, + { + "name": "icinga2", + "purpose": "Collecter la disponibilité, les incidents, les SLA internes et les tendances.", + "input": { + "apiUrl": "https://icinga.example.tld:5665/v1", + "usernameEnv": "ICINGA_USER", + "passwordEnv": "ICINGA_PASSWORD" + }, + "producesEvidence": ["uptime-reports", "incident-history", "alert-summary"] + }, + { + "name": "keycloak", + "purpose": "Collecter les rôles administrateurs, MFA et revues d'accès.", + "input": { + "baseUrl": "https://keycloak.example.tld", + "realm": "master", + "tokenEnv": "KEYCLOAK_TOKEN" + }, + "producesEvidence": ["admin-role-exports", "mfa-status", "access-review-snapshots"] + } + ] +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..3558fb4 --- /dev/null +++ b/server/index.js @@ -0,0 +1,76 @@ +import http from "node:http"; +import { URL } from "node:url"; +import { host, port } from "./config.js"; +import { readJsonBody, sendJson, sendText } from "./lib/http.js"; +import { listEvidenceArtifacts, loadRegistry, resetRegistry, writeEvidenceArtifact, writeRegistry } from "./lib/store.js"; +import { validateRegistry } from "../src/lib/validation.js"; + +const server = http.createServer(async (req, res) => { + if (!req.url) return sendJson(res, 400, { error: "Missing URL." }); + const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`); + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + return res.end(); + } + + try { + if (req.method === "GET" && url.pathname === "/health") { + return sendText(res, 200, "ok"); + } + + if (req.method === "GET" && url.pathname === "/api/health") { + const registry = await loadRegistry(); + const validation = validateRegistry(registry); + return sendJson(res, 200, { + ok: true, + service: "soc2-cockpit-api", + registryValid: validation.isValid, + warningCount: validation.warnings.length, + errorCount: validation.errors.length, + }); + } + + if (req.method === "GET" && url.pathname === "/api/registry") { + const registry = await loadRegistry(); + return sendJson(res, 200, { registry, validation: validateRegistry(registry) }); + } + + if (req.method === "PUT" && url.pathname === "/api/registry") { + const payload = await readJsonBody(req); + const result = await writeRegistry(payload); + return sendJson(res, 200, result); + } + + if (req.method === "POST" && url.pathname === "/api/registry/reset") { + const result = await resetRegistry(); + return sendJson(res, 200, result); + } + + if (req.method === "GET" && url.pathname === "/api/evidence") { + const artifacts = await listEvidenceArtifacts(); + return sendJson(res, 200, { artifacts }); + } + + if (req.method === "POST" && url.pathname === "/api/evidence") { + const payload = await readJsonBody(req); + const artifact = await writeEvidenceArtifact(payload); + return sendJson(res, 201, { artifact }); + } + + return sendJson(res, 404, { error: "Not found." }); + } catch (error) { + if (error?.validation) { + return sendJson(res, 400, { error: error.message, validation: error.validation }); + } + return sendJson(res, 500, { error: error?.message || "Internal server error." }); + } +}); + +server.listen(port, host, () => { + console.log(`SOC 2 cockpit API listening on http://${host}:${port}`); +}); diff --git a/server/lib/http.js b/server/lib/http.js new file mode 100644 index 0000000..2f018c9 --- /dev/null +++ b/server/lib/http.js @@ -0,0 +1,29 @@ +import { Buffer } from "node:buffer"; + +export function sendJson(res, status, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(body); +} + +export function sendText(res, status, text) { + res.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": Buffer.byteLength(text), + "Access-Control-Allow-Origin": "*", + }); + res.end(text); +} + +export async function readJsonBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf-8").trim(); + return raw ? JSON.parse(raw) : {}; +} diff --git a/server/lib/store.js b/server/lib/store.js new file mode 100644 index 0000000..ae71b93 --- /dev/null +++ b/server/lib/store.js @@ -0,0 +1,78 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { buildRegistryManifests, normalizeDb, validateRegistry } from "../../src/lib/validation.js"; +import { INITIAL_DB } from "../../src/model/seed.js"; +import { evidenceDir, manifestsDir, registryPath } from "../config.js"; + +async function ensureDirs() { + await fs.mkdir(manifestsDir, { recursive: true }); + await fs.mkdir(evidenceDir, { recursive: true }); +} + +export async function loadRegistry() { + await ensureDirs(); + try { + const raw = await fs.readFile(registryPath, "utf-8"); + return normalizeDb(JSON.parse(raw)); + } catch { + return normalizeDb(INITIAL_DB); + } +} + +export async function writeRegistry(input) { + await ensureDirs(); + const normalized = normalizeDb(input); + const validation = validateRegistry(normalized); + if (!validation.isValid) { + const error = new Error("Registry validation failed."); + error.validation = validation; + throw error; + } + + await fs.writeFile(registryPath, JSON.stringify(normalized, null, 2), "utf-8"); + const manifests = buildRegistryManifests(normalized); + await fs.writeFile(path.join(manifestsDir, "evidence-manifest.json"), JSON.stringify(manifests.evidenceManifest, null, 2), "utf-8"); + await fs.writeFile(path.join(manifestsDir, "auditor-pack-index.json"), JSON.stringify(manifests.auditorPackIndex, null, 2), "utf-8"); + return { registry: normalized, validation, manifests }; +} + +export async function resetRegistry() { + return writeRegistry(INITIAL_DB); +} + +export async function listEvidenceArtifacts() { + await ensureDirs(); + const names = await fs.readdir(evidenceDir).catch(() => []); + const stats = await Promise.all( + names + .filter((name) => !name.startsWith(".")) + .map(async (name) => { + const fullPath = path.join(evidenceDir, name); + const stat = await fs.stat(fullPath); + return { + filename: name, + size: stat.size, + modifiedAt: stat.mtime.toISOString(), + path: fullPath, + }; + }) + ); + return stats.sort((a, b) => a.filename.localeCompare(b.filename)); +} + +export async function writeEvidenceArtifact({ filename, contentBase64 }) { + await ensureDirs(); + if (!filename || typeof filename !== "string") throw new Error("filename is required."); + if (!contentBase64 || typeof contentBase64 !== "string") throw new Error("contentBase64 is required."); + + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + const fullPath = path.join(evidenceDir, safeName); + await fs.writeFile(fullPath, Buffer.from(contentBase64, "base64")); + const stat = await fs.stat(fullPath); + return { + filename: safeName, + size: stat.size, + modifiedAt: stat.mtime.toISOString(), + path: fullPath, + }; +} diff --git a/server/scripts/seed-registry.mjs b/server/scripts/seed-registry.mjs new file mode 100644 index 0000000..18d7b0c --- /dev/null +++ b/server/scripts/seed-registry.mjs @@ -0,0 +1,4 @@ +import { resetRegistry } from "../lib/store.js"; + +const payload = await resetRegistry(); +console.log(`Registry seeded. Evidence items: ${payload.registry.evidence.length}`); diff --git a/src/App.jsx b/src/App.jsx index 692996d..275691b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -96,7 +96,7 @@ function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPilla export default function App() { const registry = useSoc2Registry(); - const { db, lastSavedAt, importError, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry; + const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry; const [activeTab, setActiveTab] = useState("dashboard"); const [selectedPillar, setSelectedPillar] = useState("security"); @@ -170,7 +170,7 @@ export default function App() { {blocker ? Blocage: Security < 60 : null}

Assistant web de conformité SOC 2 orienté preuve, remédiation et exposition auditeur

-

Architecture modulaire, source de vérité versionnable, radar des 5 piliers, bibliothèque de preuves et salle auditeur.

+

Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.

@@ -181,7 +181,7 @@ export default function App() { - +
diff --git a/src/features/hooks/useSoc2Registry.js b/src/features/hooks/useSoc2Registry.js index d68e279..bd05c42 100644 --- a/src/features/hooks/useSoc2Registry.js +++ b/src/features/hooks/useSoc2Registry.js @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { INITIAL_DB } from "../../model/seed.js"; import { STORAGE_KEY, TODAY } from "../../model/schema.js"; import { clone } from "../../lib/utils.js"; import { normalizeDb } from "../../lib/validation.js"; +import { fetchRegistryFromApi, getSyncMode, resetRegistryOnApi, saveRegistryToApi } from "../../lib/apiClient.js"; function loadDb() { if (typeof window === "undefined") return clone(INITIAL_DB); @@ -16,15 +17,58 @@ function loadDb() { } export function useSoc2Registry() { + const syncMode = getSyncMode(); const [db, setDb] = useState(() => loadDb()); const [lastSavedAt, setLastSavedAt] = useState(null); const [importError, setImportError] = useState(""); + const [syncStatus, setSyncStatus] = useState(syncMode === "api" ? "connecting" : "local"); + const skipFirstApiSave = useRef(true); useEffect(() => { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2)); - setLastSavedAt(new Date().toLocaleString("fr-CA")); - }, [db]); + if (syncMode !== "api") return; + let cancelled = false; + fetchRegistryFromApi() + .then((remote) => { + if (cancelled) return; + setDb(normalizeDb(remote)); + setSyncStatus("connected"); + setLastSavedAt(new Date().toLocaleString("fr-CA")); + }) + .catch(() => { + if (cancelled) return; + setSyncStatus("degraded"); + }); + return () => { + cancelled = true; + }; + }, [syncMode]); + + useEffect(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2)); + } + + if (syncMode !== "api") { + setLastSavedAt(new Date().toLocaleString("fr-CA")); + return; + } + + if (skipFirstApiSave.current) { + skipFirstApiSave.current = false; + return; + } + + const timer = window.setTimeout(() => { + saveRegistryToApi(db) + .then(() => { + setSyncStatus("connected"); + setLastSavedAt(new Date().toLocaleString("fr-CA")); + }) + .catch(() => setSyncStatus("degraded")); + }, 500); + + return () => window.clearTimeout(timer); + }, [db, syncMode]); const updateMilestoneStage = (pillarId, milestoneId, nextStage) => { setDb((current) => ({ @@ -171,6 +215,15 @@ export function useSoc2Registry() { }; const resetDemo = () => { + if (syncMode === "api") { + resetRegistryOnApi() + .then((payload) => { + setDb(normalizeDb(payload.registry)); + setSyncStatus("connected"); + }) + .catch(() => setSyncStatus("degraded")); + return; + } setDb(clone(INITIAL_DB)); }; @@ -178,6 +231,8 @@ export function useSoc2Registry() { db, lastSavedAt, importError, + syncMode, + syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js new file mode 100644 index 0000000..4ccf7f1 --- /dev/null +++ b/src/lib/apiClient.js @@ -0,0 +1,52 @@ +const apiBase = (import.meta.env.VITE_SOC2_API_BASE_URL || "http://localhost:4177/api").replace(/\/$/, ""); + +async function readJson(response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + return payload; +} + +export function getSyncMode() { + return import.meta.env.VITE_SOC2_SYNC_MODE === "api" ? "api" : "local"; +} + +export async function fetchRegistryFromApi() { + const response = await fetch(`${apiBase}/registry`); + const payload = await readJson(response); + return payload.registry; +} + +export async function saveRegistryToApi(registry) { + const response = await fetch(`${apiBase}/registry`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registry), + }); + return readJson(response); +} + +export async function fetchServerStatus() { + const response = await fetch(`${apiBase}/health`); + return readJson(response); +} + +export async function resetRegistryOnApi() { + const response = await fetch(`${apiBase}/registry/reset`, { method: "POST" }); + return readJson(response); +} + +export async function listEvidenceArtifacts() { + const response = await fetch(`${apiBase}/evidence`); + return readJson(response); +} + +export async function writeEvidenceArtifact({ filename, contentBase64, mimeType = "application/octet-stream" }) { + const response = await fetch(`${apiBase}/evidence`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename, contentBase64, mimeType }), + }); + return readJson(response); +}