From c59eb57bf8f26a966b2bb28a21a0b9172f5597de Mon Sep 17 00:00:00 2001 From: Dan G Date: Thu, 30 Jul 2026 11:20:24 +0200 Subject: [PATCH 1/2] feat: persist audit history --- .openai/hosting.json | 3 +- README.md | 26 ++- app/api/audit-history/route.ts | 213 +++++++++++++++++++++++++ app/audit-history-view.tsx | 199 +++++++++++++++++++++++ app/globals.css | 157 +++++++++++++++++- app/page.tsx | 198 +++++++++++++++-------- db/index.ts | 41 ++++- db/schema.ts | 29 +++- drizzle/0000_tranquil_forge.sql | 16 ++ drizzle/meta/0000_snapshot.json | 121 ++++++++++++++ drizzle/meta/_journal.json | 12 +- lib/audit-history.ts | 273 ++++++++++++++++++++++++++++++++ package-lock.json | 1 + package.json | 3 +- tests/audit-history.test.ts | 177 +++++++++++++++++++++ tests/rendered-html.test.mjs | 133 ++++++++++++++-- 16 files changed, 1503 insertions(+), 99 deletions(-) create mode 100644 app/api/audit-history/route.ts create mode 100644 app/audit-history-view.tsx create mode 100644 drizzle/0000_tranquil_forge.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 lib/audit-history.ts create mode 100644 tests/audit-history.test.ts 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..f5be948 100644 --- a/README.md +++ b/README.md @@ -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 ; @@ -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 à @@ -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 @@ -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..d97bc75 --- /dev/null +++ b/app/api/audit-history/route.ts @@ -0,0 +1,213 @@ +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", + 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 ( + <> +
+
+ +