diff --git a/CLAUDE.md b/CLAUDE.md index 1ec00f0f..65601c13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,5 +76,7 @@ Criticality : score ≥ 7.0 = critical · ≥ 4.0 = high · ≥ 2.0 = medium · 24. ~~Corrections post-cycle-de-vie : `created_by` + suppression du legacy `/risk-management`~~ ✅ fait le 16/07/2026 (branche `fix/remove-legacy-risk-management`). **(a) `created_by`** : la note « `CreateRisk` ne renseigne pas `created_by` (reste `uuid.Nil`) » était **périmée** — vérifié live (nouveau risque via API → `created_by` = id réel de l'admin `admin@opendefender.io`) ; le handler passe `mwCtx.UserID` au use case depuis le fix `SetContext` du 08/07. Seul le chemin CTI auto-création met **volontairement** `uuid.Nil` (`matcher.go:79`, commenté « auto-generated, no human author ») — laissé tel quel (intentionnel). Note retirée de ROADMAP/CLAUDE. **(b) Legacy `/risk-management/*` supprimé** (la « meilleure solution » demandée) : ce sous-système **dupliquait** l'entité `Risk` (`RiskRegister`/`RiskTreatmentPlan`/`RiskMonitoringReview`/`RiskDecision`/…), ses tables n'étaient **jamais dans `AutoMigrate` (→ 500 systématique)**, ses requêtes `First(&x,"id = ?",id)` étaient **sans filtre tenant (fuite cross-tenant)**, et son front (`pages/RiskManagement.tsx` + `hooks/useRiskManagement` + 9 composants de phase + `api/riskManagementService`) était **orphelin** (`/risk-management` redirige déjà vers `/risks`). Supprimés : **backend** `internal/domain/risk_management.go`, `internal/service/risk_management_service.go`, `internal/handler/risk_management_handler.go`, `internal/infrastructure/repository/risk_management_repositories.go` + le bloc de routes dans `main.go` (remplacé par un commentaire pointant vers `POST /risks/:id/transition`) ; **frontend** `pages/RiskManagement.tsx`, `hooks/useRiskManagement.ts`, `api/riskManagementService.ts`, `features/risks/components/Risk{Identification,Analysis,Treatment,Monitoring,Review,Communication}Phase.tsx` + `Risk{DecisionManagement,AuditCompliance,ManagementPolicy,Details}.tsx`, `pages/RiskRegister.tsx` (orphelin qui importait le `RiskDetails` supprimé), `__tests__/api.test.ts` (testait uniquement l'API legacy, dont des endpoints inexistants `/treat`/`/monitor`/`/communicate`). Redirect `/risk-management`→`/risks` **conservé** (deep links). Le cycle de vie ISO 31000 sur l'entité `Risk` réelle (item 23) remplace intégralement ce legacy. `go build`/`vet` verts, `tsc -b`/`vite build` verts ; `TestRiskCRUDFlow` reste rouge (**pré-existant, confirmé au commit parent `62191679` via worktree**, indépendant de cette suppression). 25. ~~Gestion des vulnérabilités — module dédié (7 intégrations + priorisation risk-based)~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feat/vulnerability-management`). Demande utilisateur explicite : intégration Nessus/OpenVAS/Qualys/Microsoft Defender/AWS Inspector/Azure Defender/CrowdStrike + priorisation par CVSS/exploitabilité/criticité métier/actifs concernés. **Vérification préalable** : aucun module de vulnérabilités dédié n'existait — le scanner (Module 6) produit des `FindingDiscovery` transitoires (preview Redis) et le CTI a des `CTIVulnerability` (données de flux NVD/CISA), mais **pas de registre de vulnérabilités persistant, par actif, priorisé**. Livré — **backend** : `domain.Vulnerability` (tenant-scoped : CVE, CVSS+vector, sévérité, **EPSS**, **KEV**, exploit dispo+maturité, criticité métier snapshot de l'actif lié, **AffectedAssetsCount** = blast radius, source, statut de remédiation open→triaged→in_remediation→remediated/accepted/false_positive, **PriorityScore 0–100 + tier P1–P4** ; `DedupKey` ; dans `AutoMigrate`) + port `VulnerabilityRepository` + impl Gorm (`Upsert` par dedup qui rafraîchit les scores mais **préserve le statut de triage**, List filtres/tri/pagination, Stats agrégées, `CountAffectedAssets` distinct par CVE ; isolation tenant partout). **`pkg/vulnprio`** — moteur de priorisation **pur et déterministe** (8 tests) combinant les 4 axes demandés en pondération documentée : **CVSS 0.40** · **exploitabilité 0.30** (max(EPSS, KEV→0.95, exploit→0.70, maturité) ; **KEV plancher le score à 80 = P1** « patch now ») · **criticité métier 0.20** (facteur `AssetCriticality.ScoreFactor` de l'actif lié) · **actifs concernés 0.10** (blast radius, échelle log) → score 0–100 + tier + explication lisible. **`internal/vulnscan`** — couche d'intégration : `NormalizedFinding` + un **normaliseur par produit** (mapping du JSON natif de Nessus/OpenVAS/Qualys/Defender-TVM/Inspector-inspector2/Azure-subassessment/CrowdStrike-Spotlight → forme commune, extraction CVE robuste par regex, getters tolérants) + `Connectors()` (métadonnées UI ; AWS Inspector `live_pull=true` car SDK déjà vendored, les autres = import + seam honnête, jamais de données fabriquées). **`application/vulnerability`** — use cases 1-fichier tenant-safe + erreurs typées : `Ingest` (normalise→résout l'actif par id/hostname/external-id→**recompte le blast radius post-upsert et re-priorise**→upsert), List/Get/UpdateStatus/Delete/Stats (+ tests : Nessus normalisé+priorisé, re-ingest = update pas doublon, source inconnue 400, batch vide 400, update-status success/cross-tenant→404/invalide 400). **Handler** `vulnerability_handler.go` + routes `protected` gardées `vulnerabilities:{read,update,delete}` (`/vulnerabilities`, `/:id`, `/:id/status`, `/stats`, `/ingest`, `/vulnerability-connectors` ; **statiques avant `/:id`** — même piège Fiber que les assets). **Frontend** `features/vulnerabilities` : `vulnerabilityService` (typé, zéro `any`) + hooks React Query + `VulnerabilitiesPage` (KPI Total/Ouvertes/P1/KEV/Exploitables, filtres par tier/sévérité/KEV + recherche, **table triée par priorité** avec badge tier+score+flamme KEV, CVSS coloré, actif, source, statut) + **drawer** (breakdown de priorisation, CVSS/EPSS/signaux KEV/exploit, actif+criticité, blast radius, remédiation, **cycle de statut** gardé par permission, suppression) + **IngestModal** (choix source + collage du JSON natif + exemples) + **ConnectorsPanel** (7 connecteurs par catégorie network/EDR/cloud, badges Import/Live) ; item sidebar « Vulnérabilités » (icône Bug, groupe Sécurité) + route `/vulnerabilities` + i18n FR/EN (`n_vulns`). **Preuves live (Postgres:5434, binaire :8091)** : `GET /vulnerability-connectors` = 7 ; ingest Nessus Log4Shell → CVE-2021-44228 extraite, sévérité critique, **matché à web-01 par hostname**, criticité métier CRITICAL, priorité 62.2/P2 ; CrowdStrike `exploit_status:90` → exploit_available+maturité high, 76.4 ; **manual KEV CVSS 5.0 → floored 80/P1** (explication « floored to P1 by CISA-KEV ») ; **blast radius : même CVE sur web-03 → affected=3 → 66.97** (« 3 affected assets ») ; list triée P1 80 > P2 76.4 > P2 62.2 ; stats total/open/kev/exploit/bySeverity/byTier ; update-status→remediated (invalide 400) ; delete 204 → get 404 ; get random 404 ; ingest bad source 400 ; **capture headless de la page live** (nav active, KPI, table triée, P1 KEV en flamme). `go build`/`vet`/`go test ./pkg/vulnprio/... ./internal/application/vulnerability/...` verts, `tsc -b`/`vite build` verts. **Restes honnêtes** : les signaux EPSS/KEV viennent aujourd'hui du finding ingéré (enrichissement auto depuis le moteur CTI = prochaine itération) ; le live-pull réel des 6 connecteurs REST reste un seam honnête (AWS Inspector câblé SDK) ; pas encore de lien vuln→risque auto ; quelques vulnérabilités + actifs (web-01/db-02/web-03) laissés en **données de démo** dans la DB dev (comme les ~120 contrôles ISO d'une session passée) ; frontend prouvé par tsc/build + capture headless (le pilotage CDP interactif reste bloqué par le sandbox). 26. ~~Gestion des contrôles — catalogues de référentiels internationaux + personnalisation~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feat/compliance-frameworks-intl`). Demande utilisateur : ISO 27001 / NIST CSF / CIS Controls / PCI-DSS / HIPAA / SOC 2 / ANTIC-CM / COBAC / BCEAO + personnalisation des contrôles. **Vérification préalable** : ISO 27001:2022 (93), ANTIC-CM (25), BCEAO (35), COBAC (45) existaient déjà et sont cités ; la **personnalisation** (référentiel vierge + contrôle ad-hoc) existait (item 16). **Manquaient** : NIST CSF, CIS, PCI-DSS, HIPAA, SOC 2. **Architecture plugin découverte** : chaque catalogue = un fichier `pkg/compliance/catalog_*.go` avec `init()`→`register(Catalog{Key,Name,Version,Available,Controls:[]CatalogControl{code,name,desc,sourceRef}})` ; `GET /compliance/catalogs` renvoie `compliance.List()` et le modal d'import du frontend le consomme déjà → **ajouter un catalogue le rend importable partout sans toucher au handler ni au frontend**. Livré — **5 nouveaux catalogues** (un commit chacun) : **NIST CSF 2.0** (22 catégories des 6 fonctions GOVERN/IDENTIFY/PROTECT/DETECT/RESPOND/RECOVER, IDs GV.OC…RC.CO), **CIS Controls v8** (18 contrôles), **PCI DSS 4.0** (12 exigences), **HIPAA Security Rule** (22 standards Administratif/Physique/Technique/Organisationnel/Documentation, **citations 45 CFR §164.308–316**), **SOC 2 / AICPA TSC 2017** (51 : Common Criteria complet CC1.1–CC9.2 + Availability A1 + Confidentiality C1 + Processing Integrity PI1 + Privacy P1–P8). Approche identique à l'ISO existant : **IDs/codes structurels = structure publique du référentiel (fiables), descriptions = résumés originaux** (le texte propriétaire des standards n'est pas recopié) ; chaque contrôle cite sa source (`SourceReference`). `TestNoOrphanControls` (codes uniques + citation par contrôle) + nouveau `TestExpectedControlCounts` (22/18/12/22/51, garde-fou anti-troncature) verts. **Preuves live (Postgres:5434, binaire :8091)** : `GET /compliance/catalogs` → **10 catalogues** (9 dispo + placeholder `cm-loi-2024-017`) avec bons comptes ; création framework + `import-catalog soc2-tsc` → **51 contrôles** créés, CC6.1 cite « AICPA TSC 2017, CC6.1 » ; import `nist-csf-2.0` → 22 ; **personnalisation** : référentiel vierge « Mon référentiel interne » + contrôle ad-hoc CUSTOM-1 « Politique de télétravail sécurisé » (status not_implemented) ; suppression 204. **Capture headless** de l'écran Conformité (« 288 contrôles sur 8 référentiels », carte « CIS Controls 0/18 »). `go build`/`vet`/`go test ./pkg/compliance/...` verts (aucun changement frontend requis). **Restes honnêtes** : catalogues modélisés au niveau catégorie/exigence/standard (les sous-contrôles fins — subcategories NIST, safeguards CIS, sous-exigences PCI — s'ajoutent en ad-hoc sur le framework importé) ; **cross-mapping entre référentiels toujours absent** ; frameworks V5 restants (DORA, NIS2, GDPR JSON) hors périmètre ; 5 frameworks importés laissés en **données de démo** dans la DB dev (supprimables via l'UI). Piège CORS mémo : la capture headless doit passer par un vite sur **localhost:5173** (seule origine autorisée, main.go:350). -13. Hors scope immédiat mais à planifier : passe d'animation complète sur les pages restantes (Reports, Marketplace, CustomFields, Users, Roles, Tenants, AuditLogs, TokenManagement — non touchées cette session, seul le Dashboard a reçu un vrai polish) ; les deux vocabulaires `RiskStatus` (lowercase vs uppercase) ne sont pas unifiés, seulement rendus non-fatals côté frontend — un vrai nettoyage nécessiterait de choisir un seul vocabulaire côté backend ; `CreateRisk` ne renseigne pas `created_by` depuis le contexte réel ; implémenter `/analytics/security-score` et `/analytics/assets/statistics` (routes jamais créées, widgets en repli gracieux) ; ~350 findings lint frontend ; 7 fichiers de tests frontend en échec (pré-existants, build) ; rétrofit du client OpenAPI généré sur Risk/Mitigation (pré-existant, M1) ; `TestRiskCRUDFlow`/`TestSetupMFA_Success` en échec (pré-existants, découverts pendant M1 — `TestStartAndStop`/`TestRateLimit_DifferentIPs` flaky, repassent au vert en isolation) ; `src/hooks/useAssetStore.ts` reste un chemin de données non contract-first (utilisé par les sélecteurs d'assets de `CreateRiskModal`/`EditRiskModal`/`DashboardGrid`, volontairement non touché pendant M3). +27. ~~« 5. Conformité » — compléter la liste des référentiels + Gap Analysis + Audits + Plans de remédiation~~ ✅ fait + **vérifié live le 16/07/2026** (branche `feature/compliance-management`, partie de master via stack sur `feat/compliance-frameworks-intl`). Demande utilisateur (spec « 5. Conformité ») : référentiels cibles ISO 27001/27005/31000, NIST CSF/800-53, PCI DSS, RGPD, DORA, NIS2, SOX + suivi/preuves/**audits**/**gap analysis**/**remédiation**, et « dans importer un référentiel, il n'y a pas tous les référentiels ». **Analyse préalable** (lecture code) : suivi (dashboards/scores) et gestion des preuves = **déjà faits** ; **audits, gap analysis, remédiation = absents/factices** (bouton « Voir les écarts » sans `onClick`, mitigations liées aux risques pas aux contrôles) ; **7 catalogues manquants**. Livré — **(a) 7 catalogues** (pattern plugin, un `catalog_*.go` + `register()` → auto-exposé) : ISO/IEC 27005:2022 (19, activités du processus), ISO 31000:2018 (22, 8 principes + cadre + processus), NIST SP 800-53 Rev.5 (20 familles AC…SR), RGPD/UE 2016/679 (22 articles, **FR**), DORA/UE 2022/2554 (19, 5 piliers, **FR**), NIS2/UE 2022/2555 (12, art. 21 mesures a→j, **FR**), SOX 2002 (10, sections 302/404/… + ITGC). IDs = structure publique, descriptions = résumés originaux + citation source ; `TestExpectedControlCounts` verrouille les comptes. **(b) Gap Analysis** : `GetGapAnalysisUseCase` (réutilise `ListFrameworks`/`ListControlsByFramework`/`CountEvidencesByFramework`, zéro nouvelle méthode repo, tenant-scoped, écart = statut ≠ implemented ∧ ≠ not_applicable) → `GET /compliance/gap-analysis?framework_id=` → `GapAnalysisPage` (jauge couverture, filtres par référentiel, écarts groupés + `control_code`/preuves/source, bouton « Remédier » par écart) ; bouton « Voir les écarts » enfin câblé. **(c) Audits** (`domain.ComplianceAudit`, AutoMigrate) + **(d) Remédiation** (`domain.RemediationPlan` lié au contrôle+audit) : Clean Architecture complète (domaine → `GormComplianceAuditRepository` (une impl, 2 agrégats, tenant filtré sur chaque query, forged id cross-tenant → `NewNotFoundError`) → use cases 1-struct dans `application/complianceaudit` → `ComplianceAuditHandler` → routes `/compliance/audits` + `/compliance/remediations` gardées `compliance:audits|remediations:read|write` (admin a `*`)). Front : `AuditsPage` (planifier/lifecycle inline/historique), `RemediationPage` (priorité/statut/échéance/overdue), dialogs (primitives dc.html exportées), hub nav sur `ComplianceScreen`. **Piège corrigé** : `writeAppError`→`HTTPStatusFromError` ne mappe que `*AppError` → le sentinel nu `domain.ErrNotFound` tombait en **500** ; remplacé par `domain.NewNotFoundError(...)` dans les use cases Get/Update + repo `RowsAffected==0` + gap unknown-framework. Tests : `TestExpectedControlCounts` (7 comptes) + `gap_analysis_test.go` (3) + `gorm_compliance_audit_repository_test.go` (7 sqlite, isolation tenant sur les 2 agrégats + filtre contrôle). **Preuves live (Postgres:5434, binaire :8092)** : `GET /compliance/catalogs` → **17 catalogues** (16 dispo + placeholder), les 7 nouveaux présents/bons comptes ; import DORA (19) ; gap-analysis (307 contrôles/255 écarts, DORA Verify 19/19/0 %) ; audit create(planned)→complete (completed_at set) ; remédiation liée à un contrôle (framework dérivé, `control_code`=Art.10 enrichi)→complete ; validations 400 (type/titre/lien contrôle) ; **404 corrects** (get/delete audit inexistant, patch remédiation inexistante, gap framework inconnu). `go build`/`vet`/tests verts (`TestRiskCRUDFlow` toujours rouge, **pré-existant**), `tsc -b`/`vite build` verts. **Restes honnêtes** : endpoints audits/gap/remédiation **pas encore dans `docs/openapi.yaml`** (types front écrits à la main, `no any`) ; cross-mapping entre référentiels toujours absent ; frontend prouvé par tsc/build + endpoints live (pilotage CDP interactif bloqué par le sandbox) ; une itération future pourrait auto-générer des plans de remédiation depuis un audit terminé. +28. ~~« 5. Conformité » — follow-ups : auto-remédiation depuis un audit + cross-mapping entre référentiels + OpenAPI contract-first + TestRiskCRUDFlow~~ ✅ fait + **vérifié live le 17/07/2026** (même branche `feature/compliance-management`, 4 commits atomiques). Les 5 points d'attention laissés par l'item 27, traités. **(a) Auto-génération de remédiation depuis un audit terminé** : `GenerateRemediationsFromAuditUseCase` (deps audit+remédiation+compliance repos) → `POST /compliance/audits/:id/generate-remediations` ouvre un plan pour chaque écart (not_implemented/in_progress) du référentiel de l'audit ; **exige un audit rattaché à un référentiel** (programme-wide → 400) ; priorité déduite (not_implemented→high, in_progress→medium) ; **idempotent** (saute un contrôle ayant déjà un plan non-annulé → renvoie created/skipped) ; gardé `compliance:remediations:write` ; 4 tests use-case (gaps-only, skip actif, programme-wide 400, audit inconnu 404). Front : bouton « Remédier » par audit rattaché sur `AuditsPage` → toast créés/ignorés → navigue vers le registre. **(b) Cross-mapping entre référentiels** (le vrai manque restant) : `domain.ControlMapping` (crosswalk **non-dirigé** tenant-scoped entre deux contrôles + `MappingRelation` equivalent/partial/related ; dans `AutoMigrate`) → `ControlMappingRepository` + impl Gorm (**`Exists` teste les DEUX directions**, isolation tenant sur chaque query, delete forged-id → not found) → use cases `application/compliance` (Create = **double garde cross-tenant** via `GetControlByID` sur les deux + refus self-link + refus doublon ; List enrichi source/cible code/nom/référentiel avec cache ; Delete) → handler + routes `GET/POST /compliance/control-mappings` + `DELETE /:mappingId` (**statique avant `/controls/:id`** — piège Fiber) gardées lecture/`compliance:controls:update` ; tests repo (Exists symétrique, list all/by-control, isolation+delete). Front : `types`+`service`+`useControlMappings` + **`ControlMappingsSection`** dans le drawer de contrôle (`FrameworkDetail` `EvidenceDrawer`) — liste l'autre côté du lien + formulaire (référentiel cible→contrôle→relation). **(c) OpenAPI + contract-first** : les schémas GapAnalysis/GapControl/FrameworkGapSummary/ComplianceAudit/Create+UpdateAuditInput/RemediationPlan/Create+UpdateRemediationInput/GenerateRemediationsResult/ControlMapping/CreateControlMappingInput + tous les paths (gap-analysis, audits ×5, generate-remediations, remediations ×3, control-mappings ×3) ajoutés à `docs/openapi.yaml` **avec `required`** (sinon champs tous optionnels côté généré) ; `npm run generate:api-types` → `openapi.generated.ts` ; **`types/compliance.ts` re-pointé** sur les schémas générés (comme les anciens types) — enums `AuditType/AuditStatus/RemediationPriority/RemediationStatus/MappingRelation` **dérivés** via `NonNullable<...>` ; seul `RemediationFilter` (filtre de query, pas un body) reste écrit à la main. **(d) `TestRiskCRUDFlow`** (rouge de longue date, pré-existant) : deux bugs **de test** — payloads envoyaient `probability:4` (ancienne échelle 1–5) qui échoue la validation `min=0,max=1` (Score Engine 0.0–1.0) → 400 ; et le `CREATE TABLE risks` sqlite manuel avait **dérivé** (manquait `tenant_id` + ~15 colonnes que l'INSERT/RETURNING du modèle touche) → « table risks has no column named tenant_id ». Corrigés → **au vert**. **Preuves live (Postgres:5434, binaire :8092)** : 17 catalogues (les 7 bons comptes : 27005=19/31000=22/800-53=20/RGPD=22/DORA=19/NIS2=12/SOX=10) ; auto-remédiation **19 créés (HTTP 201)** → re-run **19 ignorés** (idempotent) ; cross-mapping create **201** (cible enrichie) / self-link **400** / doublon inverse **409** / list-par-autre-contrôle **symétrique** / delete **204** ; gap-analysis tous (326 contrôles/274 écarts/10 référentiels) + scoped fw (19/19/0 %) ; audit programme-wide→generate **400**. `go build ./...`/`vet`/`go test` (handler+compliance+complianceaudit+repository+pkg/compliance) **verts** (`TestRiskCRUDFlow` désormais vert), `tsc -b`/`vite build` verts. **Restes honnêtes** : cross-mapping = liens **manuels** (pas de crosswalk curé pré-rempli ISO↔NIST) ; frontend prouvé par tsc/build + endpoints live (recréer le harnais de capture headless `_authboot.html`+vite n'était pas sur cette branche — pilotage CDP interactif toujours bloqué par le sandbox) ; `TestSetupMFA_Success` et le lint frontend restent hors périmètre. +13. Hors scope immédiat mais à planifier : passe d'animation complète sur les pages restantes (Reports, Marketplace, CustomFields, Users, Roles, Tenants, AuditLogs, TokenManagement — non touchées cette session, seul le Dashboard a reçu un vrai polish) (Reports, Marketplace, CustomFields, Users, Roles, Tenants, AuditLogs, TokenManagement — non touchées cette session, seul le Dashboard a reçu un vrai polish) ; les deux vocabulaires `RiskStatus` (lowercase vs uppercase) ne sont pas unifiés, seulement rendus non-fatals côté frontend — un vrai nettoyage nécessiterait de choisir un seul vocabulaire côté backend ; `CreateRisk` ne renseigne pas `created_by` depuis le contexte réel ; implémenter `/analytics/security-score` et `/analytics/assets/statistics` (routes jamais créées, widgets en repli gracieux) ; ~350 findings lint frontend ; 7 fichiers de tests frontend en échec (pré-existants, build) ; rétrofit du client OpenAPI généré sur Risk/Mitigation (pré-existant, M1) ; `TestRiskCRUDFlow`/`TestSetupMFA_Success` en échec (pré-existants, découverts pendant M1 — `TestStartAndStop`/`TestRateLimit_DifferentIPs` flaky, repassent au vert en isolation) ; `src/hooks/useAssetStore.ts` reste un chemin de données non contract-first (utilisé par les sélecteurs d'assets de `CreateRiskModal`/`EditRiskModal`/`DashboardGrid`, volontairement non touché pendant M3). diff --git a/ROADMAP.md b/ROADMAP.md index 9905a736..b6fa80fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,7 +69,7 @@ il n'est pas encore la plateforme différenciante du Master Prompt V5 (Wave 2/3) | **9. Notifications** | ✅ (cœur) | `pkg/notify`, `notification_service`, `notification_handler`, centre de notifs frontend. | Canaux **Email (Resend/SMTP), Slack, Webhook signé** non prouvés live. Webhooks sortants à vérifier. | | **10. IA Advisor** | 🟡 | **Fondation `pkg/ai` créée** (10/07, 1er vrai client LLM du repo : interface `Advisor`, `ClaudeAdvisor` sur `anthropics/anthropic-sdk-go` modèle `claude-opus-4-8`, `TemplateAdvisor` fallback). `ai_risk_predictor_service`, `recommendation_service`. | Les use cases V5 (analyze/mitigations/deduplicate/prioritize/narrative/executive-summary), l'`AIAdvisorTab` dans le RiskDrawer, le streaming, le cache/rate-limit IA : **non faits** (stubs sans provider). Chemin `ClaudeAdvisor` non prouvé live (pas de clé). | | **11. Reporting & Export** | 🟡 | `pkg/report` (PDF `fpdf`, **conformité + Board Report ✅**), export CSV risques (`export_risks.go`), `export_handler`. | Pas de `pkg/export` async (jobs Redis, XLSX `excelize`, MinIO/S3, TTL). **Templates officiels COBAC/BCEAO/ISO/PCI ❌.** `ReportsPage.tsx` = maquette non câblée. | -| **12. Compliance Frameworks** | ✅ (moteur + couverture large) | Moteur M1 vérifié live ; **9 référentiels cités et importables** : ISO 27001:2022 (93) + **NIST CSF 2.0 (22 catégories)** + **CIS Controls v8 (18)** + **PCI DSS 4.0 (12 exigences)** + **HIPAA Security Rule (22 standards, cit. 45 CFR §164)** + **SOC 2 / AICPA TSC (51 : CC1–CC9 + A/C/PI/P)** + BCEAO (35) + ANTIC-CM (25) + COBAC (45) — chacun cité article/section par contrôle (`TestNoOrphanControls` + counts). Architecture plugin : un fichier `pkg/compliance/catalog_*.go` + `register()` → le catalogue remonte automatiquement dans `GET /compliance/catalogs` et devient importable, **zéro changement handler/frontend**. Frameworks **tenant-scoped** (migration 0030). **Gestion complète (13/07)** : créer/importer/supprimer référentiel + contrôle (RBAC), **personnalisation** (référentiel vierge + contrôle ad-hoc, vérifié live), preuve en chip cliquable, **progression temps réel**, **seuil de preuve obligatoire (mode strict, back+front)**. **Prouvé live 16/07** : les 10 catalogues listés avec bons comptes ; import SOC 2 (51) + NIST (22) ; contrôle ad-hoc CUSTOM-1 ; capture de l'écran Conformité (8 référentiels/288 contrôles). | Frameworks V5 restants (DORA, NIS2, GDPR JSON…) ; **cross-mapping ❌**, gap-analysis partiel ; catalogues modélisés au niveau catégorie/exigence (sous-contrôles ajoutables en ad-hoc) ; 1 placeholder (`cm-loi-2024-017`, texte non fourni). | +| **12. Compliance Frameworks** | ✅ (moteur + couverture large + audits + gap + remédiation) | Moteur M1 vérifié live ; **16 référentiels cités et importables** : ISO 27001:2022 (93) + **ISO/IEC 27005:2022 (19, processus de gestion du risque)** + **ISO 31000:2018 (22, principes/cadre/processus)** + **NIST CSF 2.0 (22)** + **NIST SP 800-53 Rev.5 (20 familles)** + **CIS Controls v8 (18)** + **PCI DSS 4.0 (12)** + **HIPAA Security Rule (22, cit. 45 CFR §164)** + **SOC 2 / AICPA TSC (51)** + **RGPD / UE 2016/679 (22 articles, FR)** + **DORA / UE 2022/2554 (19, 5 piliers, FR)** + **NIS2 / UE 2022/2555 (12, art. 21 mesures, FR)** + **SOX 2002 (10, sections + ITGC)** + BCEAO (35) + ANTIC-CM (25) + COBAC (45) — chacun cité article/section par contrôle (`TestNoOrphanControls` + `TestExpectedControlCounts`). Architecture plugin : un fichier `pkg/compliance/catalog_*.go` + `register()` → le catalogue remonte automatiquement dans `GET /compliance/catalogs` et devient importable, **zéro changement handler/frontend**. Frameworks **tenant-scoped** (migration 0030). **Gestion complète** : créer/importer/supprimer référentiel + contrôle (RBAC), **personnalisation** (référentiel vierge + contrôle ad-hoc), preuve en chip cliquable, **progression temps réel**, **seuil de preuve obligatoire (mode strict)**. **Analyse d'écarts (Gap Analysis) ✅ (16/07, branche `feature/compliance-management`)** : `GET /compliance/gap-analysis` (tous référentiels ou 1) → contrôles non satisfaits + roll-ups par référentiel ; page dédiée (jauge, filtres, écarts groupés) branchée sur le bouton « Voir les écarts » (auparavant inerte) ; « Remédier » par écart. **Audits ✅** : `domain.ComplianceAudit` (interne/externe/certification/surveillance ; planifié→en cours→terminé/annulé ; portée référentiel/programme ; auditeur/périmètre/résumé/score/dates), CRUD `/compliance/audits`, page de planification/exécution/historique. **Plans de remédiation ✅** : `domain.RemediationPlan` lié à un contrôle (l'écart) + à l'audit d'origine ; priorité/statut/assignation/échéance ; CRUD `/compliance/remediations`, page de suivi. **Auto-génération depuis un audit terminé ✅ (17/07)** : `POST /compliance/audits/:id/generate-remediations` ouvre un plan pour chaque écart du référentiel de l'audit (priorité déduite : not_implemented→high, in_progress→medium ; idempotent — saute les écarts déjà couverts) ; bouton « Remédier » par audit → toast créés/ignorés → registre. **Cross-mapping entre référentiels ✅ (17/07)** : `domain.ControlMapping` (crosswalk non-dirigé tenant-scoped entre deux contrôles + relation équivalent/partiel/lié), repo Gorm (Exists bidirectionnel, isolation tenant), use cases (Create = double garde cross-tenant + refus self/doublon, List enrichi, Delete) → `GET/POST /compliance/control-mappings` + `DELETE /:mappingId` ; section « Correspondances » dans le drawer de contrôle (lier vers un autre référentiel). **OpenAPI ✅ (17/07)** : gap-analysis/audits/remédiation/mappings dans `docs/openapi.yaml` (schémas + paths + `required`), types régénérés, `types/compliance.ts` re-pointé contract-first (enums dérivés). **Prouvé live 16-17/07** : 17 catalogues (16 dispo + 1 placeholder) bons comptes ; import DORA (19) ; gap-analysis (tous : 326 contrôles/274 écarts ; DORA 19/19/0 %) ; audit create→complete ; auto-remédiation 19 créés (201)→re-run 19 ignorés (idempotent) ; cross-mapping create 201/self-link 400/doublon inverse 409/list symétrique/delete 204 ; audit programme-wide→400. | Catalogues modélisés au niveau catégorie/exigence (sous-contrôles ajoutables en ad-hoc) ; 1 placeholder (`cm-loi-2024-017`, texte non fourni) ; cross-mapping = liens manuels (pas de crosswalk curé pré-rempli ISO↔NIST). | | **13. Asset Management** | ✅ (inventaire + criticité + **dépendances** + historique, prouvé live 16/07) | M3 : Clean Architecture rétrofitée, snapshots historiques, criticité→Score Engine. `features/assets`. **Gestion centralisée complète (16/07, branche `feat/asset-dependency-mapping`)** : (1) **inventaire** étendu à la taxonomie GRC (Server/Application/Cloud/Database/SaaS/Storage/Network/Laptop/**Data/User/Supplier**) — icônes + chips de filtre ; (2) **classification par criticité** (déjà là, → Score Engine) ; (3) **cartographie des dépendances entre actifs** — nouveau modèle `AssetDependency` (arête dirigée tenant-scoped, 8 types de relation), repo+use cases (Create/List/Delete, gardes self-ref/doublon/cross-tenant, cascade à la suppression d'actif), handler `/asset-dependencies`, OpenAPI, `Asset Universe` **rebranché sur données réelles** (fixtures supprimées) avec éditeur de dépendances (ajout/retrait) dans le panneau ; (4) **historique des modifications** enfin exposé dans l'UI live (bouton « Historique » sur la modale d'édition → `AssetHistoryDrawer`). Preuves live : graph 7 actifs · 7 liens rendu, inventaire 11 types, endpoints 201/409/404/204, cascade + snapshot vérifiés. | Les 4 autres vues Universe (topology/bubbles/hierarchy) restent « coming soon ». Matching CVE via CPE dépend du CTI/Scanner (déjà câblé, item 21). | ### 1.2 Fonctionnalités avancées — Module 14.1 à 14.18 (le **moat** vs Vanta/Drata) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 797a40fa..9cb047db 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "github.com/opendefender/openrisk/internal/application/auth" "github.com/opendefender/openrisk/internal/application/board" "github.com/opendefender/openrisk/internal/application/compliance" + "github.com/opendefender/openrisk/internal/application/complianceaudit" appmitigation "github.com/opendefender/openrisk/internal/application/mitigation" notificationapp "github.com/opendefender/openrisk/internal/application/notification" "github.com/opendefender/openrisk/internal/application/risk" @@ -153,6 +154,9 @@ func main() { // Directed edges of the asset dependency graph ("cartographie des // dépendances"). Tenant-scoped; both endpoints reference assets. &domain.AssetDependency{}, + // Cross-framework control crosswalks ("cross-mapping entre référentiels"). + // Tenant-scoped undirected links between two compliance controls. + &domain.ControlMapping{}, &domain.RiskHistory{}, &domain.CustomField{}, &domain.CustomFieldTemplate{}, @@ -204,6 +208,10 @@ func main() { // sets it (a bare map[string]interface{} has no driver.Valuer). &domain.Notification{}, &domain.NotificationPreference{}, + // Compliance audits ("Audits" — plan/execute/history) and remediation + // plans ("Plans de remédiation" — close a gap, assign, track). Tenant-scoped. + &domain.ComplianceAudit{}, + &domain.RemediationPlan{}, ); err != nil { log.Fatalf("Database Migration Failed: %v", err) } @@ -643,6 +651,11 @@ func main() { deleteEvidenceUC := compliance.NewDeleteEvidenceUseCase(complianceRepo, fileStorage) downloadEvidenceUC := compliance.NewDownloadEvidenceUseCase(complianceRepo, fileStorage) getProgressUC := compliance.NewGetComplianceProgressUseCase(complianceRepo) + getGapAnalysisUC := compliance.NewGetGapAnalysisUseCase(complianceRepo) + controlMappingRepo := repository.NewGormControlMappingRepository(database.DB) + createMappingUC := compliance.NewCreateControlMappingUseCase(controlMappingRepo, complianceRepo) + listMappingsUC := compliance.NewListControlMappingsUseCase(controlMappingRepo, complianceRepo) + deleteMappingUC := compliance.NewDeleteControlMappingUseCase(controlMappingRepo) listCatalogsUC := compliance.NewListCatalogsUseCase() importCatalogUC := compliance.NewImportCatalogUseCase(complianceRepo) // M4 — official compliance report (PDF). Reuses userRepo/orgRepo (declared @@ -653,6 +666,8 @@ func main() { createControlUC, getControlUC, listControlsUC, updateControlUC, deleteControlUC, createEvidenceUC, listEvidencesUC, deleteEvidenceUC, downloadEvidenceUC, getProgressUC, listCatalogsUC, importCatalogUC, generateReportUC, + getGapAnalysisUC, + createMappingUC, listMappingsUC, deleteMappingUC, ) // NOTE: these routes sit under `protected`, whose base middleware (middleware.Protected, @@ -685,6 +700,14 @@ func main() { protected.Get("/compliance/frameworks/:frameworkId", complianceFrameworkRead, complianceHandler.GetFramework) protected.Delete("/compliance/frameworks/:frameworkId", complianceFrameworkDelete, complianceHandler.DeleteFramework) protected.Get("/compliance/frameworks/:frameworkId/progress", complianceControlRead, complianceHandler.GetProgress) + // Gap analysis ("analyse d'écarts") — every unsatisfied control across the + // tenant's frameworks (optional ?framework_id= scopes to one). + protected.Get("/compliance/gap-analysis", complianceControlRead, complianceHandler.GetGapAnalysis) + // Cross-framework control mappings ("cross-mapping entre référentiels"). Static + // path — no :param collision with /compliance/controls/:controlId. + protected.Get("/compliance/control-mappings", complianceControlRead, complianceHandler.ListControlMappings) + protected.Post("/compliance/control-mappings", complianceControlUpdate, complianceHandler.CreateControlMapping) + protected.Delete("/compliance/control-mappings/:mappingId", complianceControlUpdate, complianceHandler.DeleteControlMapping) // Official compliance report (PDF, 1-click) — reads a tenant's controls/evidence, same tier as reading them. protected.Get("/compliance/frameworks/:frameworkId/report", complianceControlRead, complianceHandler.GenerateReport) protected.Get("/compliance/frameworks/:frameworkId/controls", complianceControlRead, complianceHandler.ListControls) @@ -697,6 +720,44 @@ func main() { protected.Get("/compliance/evidences/:evidenceId/download", complianceEvidenceRead, complianceHandler.DownloadEvidence) protected.Delete("/compliance/evidences/:evidenceId", complianceEvidenceDelete, complianceHandler.DeleteEvidence) + // ------------------------------------------------------------------------- + // Compliance audits ("Audits") + remediation plans ("Plans de remédiation"). + // One Gorm repo backs both aggregates. New permission strings — admin/root + // hold "*" so they're granted; a future Profile rule can open them per-role. + // ------------------------------------------------------------------------- + complianceAuditRepo := repository.NewGormComplianceAuditRepository(database.DB) + complianceAuditHandler := handlers.NewComplianceAuditHandler( + complianceaudit.NewCreateAuditUseCase(complianceAuditRepo), + complianceaudit.NewListAuditsUseCase(complianceAuditRepo), + complianceaudit.NewGetAuditUseCase(complianceAuditRepo), + complianceaudit.NewUpdateAuditUseCase(complianceAuditRepo), + complianceaudit.NewDeleteAuditUseCase(complianceAuditRepo), + complianceaudit.NewCreateRemediationUseCase(complianceAuditRepo, complianceRepo), + complianceaudit.NewListRemediationsUseCase(complianceAuditRepo, complianceRepo), + complianceaudit.NewUpdateRemediationUseCase(complianceAuditRepo), + complianceaudit.NewDeleteRemediationUseCase(complianceAuditRepo), + complianceaudit.NewGenerateRemediationsFromAuditUseCase(complianceAuditRepo, complianceAuditRepo, complianceRepo), + ) + complianceAuditRead := middleware.RequirePermission("compliance:audits:read") + complianceAuditWrite := middleware.RequirePermission("compliance:audits:write") + complianceRemediationRead := middleware.RequirePermission("compliance:remediations:read") + complianceRemediationWrite := middleware.RequirePermission("compliance:remediations:write") + + // Static paths — registered as siblings of /compliance/frameworks etc.; no + // dynamic :segment under /compliance would greedily catch "audits"/"remediations". + protected.Get("/compliance/audits", complianceAuditRead, complianceAuditHandler.ListAudits) + protected.Post("/compliance/audits", complianceAuditWrite, complianceAuditHandler.CreateAudit) + protected.Get("/compliance/audits/:id", complianceAuditRead, complianceAuditHandler.GetAudit) + protected.Patch("/compliance/audits/:id", complianceAuditWrite, complianceAuditHandler.UpdateAudit) + protected.Delete("/compliance/audits/:id", complianceAuditWrite, complianceAuditHandler.DeleteAudit) + // One-click: open a remediation plan for every open gap under the audit's framework. + protected.Post("/compliance/audits/:id/generate-remediations", complianceRemediationWrite, complianceAuditHandler.GenerateRemediations) + + protected.Get("/compliance/remediations", complianceRemediationRead, complianceAuditHandler.ListRemediations) + protected.Post("/compliance/remediations", complianceRemediationWrite, complianceAuditHandler.CreateRemediation) + protected.Patch("/compliance/remediations/:id", complianceRemediationWrite, complianceAuditHandler.UpdateRemediation) + protected.Delete("/compliance/remediations/:id", complianceRemediationWrite, complianceAuditHandler.DeleteRemediation) + // ========================================================================= // Board Report (M4, second half — see ROADMAP.md §3 M4). // Monthly, non-technical board-of-directors report: aggregates the tenant's diff --git a/backend/internal/application/compliance/control_mapping.go b/backend/internal/application/compliance/control_mapping.go new file mode 100644 index 00000000..db2afd4b --- /dev/null +++ b/backend/internal/application/compliance/control_mapping.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +import ( + "context" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// CreateControlMappingInput is the payload for a new cross-framework mapping. +type CreateControlMappingInput struct { + SourceControlID uuid.UUID + TargetControlID uuid.UUID + Relation string + Note string +} + +// CreateControlMappingUseCase links two of the tenant's controls (normally in +// different frameworks). It validates BOTH controls belong to the tenant — a +// double cross-tenant guard — and refuses self-links and duplicates (either +// direction). +type CreateControlMappingUseCase struct { + repo domain.ControlMappingRepository + compRepo domain.ComplianceRepository +} + +func NewCreateControlMappingUseCase(repo domain.ControlMappingRepository, compRepo domain.ComplianceRepository) *CreateControlMappingUseCase { + return &CreateControlMappingUseCase{repo: repo, compRepo: compRepo} +} + +func (uc *CreateControlMappingUseCase) Execute(ctx context.Context, tenantID, createdBy uuid.UUID, in CreateControlMappingInput) (*domain.ControlMapping, error) { + if in.SourceControlID == in.TargetControlID { + return nil, domain.NewValidationError("a control cannot be mapped to itself") + } + relation, err := domain.ParseMappingRelation(in.Relation) + if err != nil { + return nil, err + } + + // Both controls must belong to THIS tenant (GetControlByID returns nil for + // another tenant's control, so a forged id can never map across tenants). + src, err := uc.compRepo.GetControlByID(ctx, in.SourceControlID, tenantID) + if err != nil { + return nil, err + } + if src == nil { + return nil, domain.NewValidationError("source control not found") + } + tgt, err := uc.compRepo.GetControlByID(ctx, in.TargetControlID, tenantID) + if err != nil { + return nil, err + } + if tgt == nil { + return nil, domain.NewValidationError("target control not found") + } + + exists, err := uc.repo.Exists(ctx, tenantID, in.SourceControlID, in.TargetControlID) + if err != nil { + return nil, err + } + if exists { + return nil, domain.NewConflictError("control mapping", "control pair") + } + + m := &domain.ControlMapping{ + TenantID: tenantID, + SourceControlID: in.SourceControlID, + TargetControlID: in.TargetControlID, + Relation: relation, + Note: in.Note, + } + if createdBy != uuid.Nil { + m.CreatedBy = &createdBy + } + if err := uc.repo.Create(ctx, m); err != nil { + return nil, err + } + // Enrich the response so the UI can render both sides immediately. + uc.enrichOne(ctx, tenantID, m, map[uuid.UUID]*domain.ComplianceControl{src.ID: src, tgt.ID: tgt}, map[uuid.UUID]*domain.ComplianceFramework{}) + return m, nil +} + +// ListControlMappingsUseCase returns the tenant's crosswalks, optionally scoped +// to one control, enriched with each side's code/name/framework. +type ListControlMappingsUseCase struct { + repo domain.ControlMappingRepository + compRepo domain.ComplianceRepository +} + +func NewListControlMappingsUseCase(repo domain.ControlMappingRepository, compRepo domain.ComplianceRepository) *ListControlMappingsUseCase { + return &ListControlMappingsUseCase{repo: repo, compRepo: compRepo} +} + +func (uc *ListControlMappingsUseCase) Execute(ctx context.Context, tenantID uuid.UUID, controlID *uuid.UUID) ([]domain.ControlMapping, error) { + mappings, err := uc.repo.List(ctx, tenantID, controlID) + if err != nil { + return nil, err + } + ctrlCache := map[uuid.UUID]*domain.ComplianceControl{} + fwCache := map[uuid.UUID]*domain.ComplianceFramework{} + for i := range mappings { + if err := uc.enrichOne(ctx, tenantID, &mappings[i], ctrlCache, fwCache); err != nil { + return nil, err + } + } + return mappings, nil +} + +// enrichOne fills the computed source/target code/name/framework fields, using +// caches to avoid re-querying shared controls/frameworks. +func (uc *ListControlMappingsUseCase) enrichOne(ctx context.Context, tenantID uuid.UUID, m *domain.ControlMapping, ctrlCache map[uuid.UUID]*domain.ComplianceControl, fwCache map[uuid.UUID]*domain.ComplianceFramework) error { + src, err := uc.lookupControl(ctx, tenantID, m.SourceControlID, ctrlCache) + if err != nil { + return err + } + tgt, err := uc.lookupControl(ctx, tenantID, m.TargetControlID, ctrlCache) + if err != nil { + return err + } + if src != nil { + m.SourceCode, m.SourceName, m.SourceFrameworkID = src.ReferenceCode, src.Name, src.FrameworkID.String() + if fw := uc.lookupFramework(ctx, tenantID, src.FrameworkID, fwCache); fw != nil { + m.SourceFrameworkName = fw.Name + } + } + if tgt != nil { + m.TargetCode, m.TargetName, m.TargetFrameworkID = tgt.ReferenceCode, tgt.Name, tgt.FrameworkID.String() + if fw := uc.lookupFramework(ctx, tenantID, tgt.FrameworkID, fwCache); fw != nil { + m.TargetFrameworkName = fw.Name + } + } + return nil +} + +func (uc *ListControlMappingsUseCase) lookupControl(ctx context.Context, tenantID, id uuid.UUID, cache map[uuid.UUID]*domain.ComplianceControl) (*domain.ComplianceControl, error) { + if c, ok := cache[id]; ok { + return c, nil + } + c, err := uc.compRepo.GetControlByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + cache[id] = c + return c, nil +} + +func (uc *ListControlMappingsUseCase) lookupFramework(ctx context.Context, tenantID, id uuid.UUID, cache map[uuid.UUID]*domain.ComplianceFramework) *domain.ComplianceFramework { + if fw, ok := cache[id]; ok { + return fw + } + fw, err := uc.compRepo.GetFrameworkByID(ctx, id, tenantID) + if err != nil { + fw = nil + } + cache[id] = fw + return fw +} + +// enrichOne on the create use case reuses the list use case's logic via a small +// shim so both paths return the same shape. +func (uc *CreateControlMappingUseCase) enrichOne(ctx context.Context, tenantID uuid.UUID, m *domain.ControlMapping, ctrlCache map[uuid.UUID]*domain.ComplianceControl, fwCache map[uuid.UUID]*domain.ComplianceFramework) { + lister := &ListControlMappingsUseCase{repo: uc.repo, compRepo: uc.compRepo} + _ = lister.enrichOne(ctx, tenantID, m, ctrlCache, fwCache) +} + +// DeleteControlMappingUseCase removes a crosswalk (tenant-scoped). +type DeleteControlMappingUseCase struct { + repo domain.ControlMappingRepository +} + +func NewDeleteControlMappingUseCase(repo domain.ControlMappingRepository) *DeleteControlMappingUseCase { + return &DeleteControlMappingUseCase{repo: repo} +} + +func (uc *DeleteControlMappingUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error { + return uc.repo.Delete(ctx, id, tenantID) +} diff --git a/backend/internal/application/compliance/gap_analysis.go b/backend/internal/application/compliance/gap_analysis.go new file mode 100644 index 00000000..0f4ced83 --- /dev/null +++ b/backend/internal/application/compliance/gap_analysis.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +import ( + "context" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// GapControl is one unsatisfied control surfaced by the gap analysis — a control +// that is neither implemented nor marked not-applicable, i.e. an open compliance +// gap the tenant must remediate. +type GapControl struct { + ControlID uuid.UUID `json:"control_id"` + FrameworkID uuid.UUID `json:"framework_id"` + FrameworkName string `json:"framework_name"` + ReferenceCode string `json:"reference_code"` + Name string `json:"name"` + Description string `json:"description"` + Status domain.ControlStatus `json:"status"` + SourceReference string `json:"source_reference"` + EvidenceCount int `json:"evidence_count"` +} + +// FrameworkGapSummary rolls up gap counts per framework so the UI can show, at a +// glance, which framework carries the most risk. +type FrameworkGapSummary struct { + FrameworkID uuid.UUID `json:"framework_id"` + FrameworkName string `json:"framework_name"` + Version string `json:"version"` + Total int `json:"total"` + Implemented int `json:"implemented"` + InProgress int `json:"in_progress"` + NotImplemented int `json:"not_implemented"` + NotApplicable int `json:"not_applicable"` + Gaps int `json:"gaps"` + PercentComplete float64 `json:"percent_complete"` +} + +// GapAnalysis is the "analyse d'écarts" DTO: every open gap across a tenant's +// frameworks, plus per-framework and overall roll-ups. Computed, not persisted. +type GapAnalysis struct { + TotalControls int `json:"total_controls"` + TotalGaps int `json:"total_gaps"` + Frameworks []FrameworkGapSummary `json:"frameworks"` + Gaps []GapControl `json:"gaps"` +} + +// GetGapAnalysisUseCase identifies unsatisfied controls across all of a tenant's +// frameworks (or a single framework when a frameworkID is supplied). It reuses the +// existing repository ports — no new persistence method needed — and stays fully +// tenant-scoped. +type GetGapAnalysisUseCase struct { + repo domain.ComplianceRepository +} + +func NewGetGapAnalysisUseCase(repo domain.ComplianceRepository) *GetGapAnalysisUseCase { + return &GetGapAnalysisUseCase{repo: repo} +} + +// isGap reports whether a control counts as an open compliance gap: everything +// that is not fully implemented and not explicitly out of scope. +func isGap(s domain.ControlStatus) bool { + return s != domain.ControlStatusImplemented && s != domain.ControlStatusNotApplicable +} + +// Execute runs the gap analysis. If frameworkID is uuid.Nil, it spans every +// framework the tenant owns; otherwise it scopes to that single framework. +func (uc *GetGapAnalysisUseCase) Execute(ctx context.Context, tenantID uuid.UUID, frameworkID uuid.UUID) (*GapAnalysis, error) { + var frameworks []domain.ComplianceFramework + + if frameworkID != uuid.Nil { + fw, err := uc.repo.GetFrameworkByID(ctx, frameworkID, tenantID) + if err != nil { + return nil, err + } + if fw == nil { + return nil, domain.NewNotFoundError("framework", frameworkID) + } + frameworks = []domain.ComplianceFramework{*fw} + } else { + all, err := uc.repo.ListFrameworks(ctx, tenantID) + if err != nil { + return nil, err + } + frameworks = all + } + + result := &GapAnalysis{ + Frameworks: make([]FrameworkGapSummary, 0, len(frameworks)), + Gaps: make([]GapControl, 0), + } + + for _, fw := range frameworks { + controls, err := uc.repo.ListControlsByFramework(ctx, tenantID, fw.ID) + if err != nil { + return nil, err + } + + // Evidence counts in one grouped query per framework (no N+1). + evCounts, err := uc.repo.CountEvidencesByFramework(ctx, tenantID, fw.ID) + if err != nil { + return nil, err + } + + summary := FrameworkGapSummary{ + FrameworkID: fw.ID, + FrameworkName: fw.Name, + Version: fw.Version, + Total: len(controls), + } + + for _, c := range controls { + switch c.Status { + case domain.ControlStatusImplemented: + summary.Implemented++ + case domain.ControlStatusInProgress: + summary.InProgress++ + case domain.ControlStatusNotApplicable: + summary.NotApplicable++ + default: + summary.NotImplemented++ + } + + if isGap(c.Status) { + summary.Gaps++ + result.Gaps = append(result.Gaps, GapControl{ + ControlID: c.ID, + FrameworkID: fw.ID, + FrameworkName: fw.Name, + ReferenceCode: c.ReferenceCode, + Name: c.Name, + Description: c.Description, + Status: c.Status, + SourceReference: c.SourceReference, + EvidenceCount: evCounts[c.ID], + }) + } + } + + applicable := summary.Total - summary.NotApplicable + if applicable > 0 { + summary.PercentComplete = float64(summary.Implemented) / float64(applicable) * 100 + } + + result.TotalControls += summary.Total + result.TotalGaps += summary.Gaps + result.Frameworks = append(result.Frameworks, summary) + } + + return result, nil +} diff --git a/backend/internal/application/compliance/gap_analysis_test.go b/backend/internal/application/compliance/gap_analysis_test.go new file mode 100644 index 00000000..c813d3f5 --- /dev/null +++ b/backend/internal/application/compliance/gap_analysis_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetGapAnalysis_AllFrameworks checks the cross-framework roll-up: a gap is +// any control that is neither implemented nor not-applicable. +func TestGetGapAnalysis_AllFrameworks(t *testing.T) { + fwID := uuid.New() + c1, c2, c3 := uuid.New(), uuid.New(), uuid.New() + repo := &MockComplianceRepository{ + listFrameworksFunc: func(ctx context.Context, tid uuid.UUID) ([]domain.ComplianceFramework, error) { + return []domain.ComplianceFramework{{ID: fwID, Name: "ISO 27001", Version: "2022"}}, nil + }, + listControlsByFrameworkFunc: func(ctx context.Context, tid, fid uuid.UUID) ([]domain.ComplianceControl, error) { + return []domain.ComplianceControl{ + {ID: c1, FrameworkID: fwID, Status: domain.ControlStatusImplemented}, + {ID: c2, FrameworkID: fwID, Status: domain.ControlStatusInProgress}, + {ID: c3, FrameworkID: fwID, Status: domain.ControlStatusNotImplemented}, + {ID: uuid.New(), FrameworkID: fwID, Status: domain.ControlStatusNotApplicable}, + }, nil + }, + countEvidencesByFwFunc: func(ctx context.Context, tid, fid uuid.UUID) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{c2: 1}, nil + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + res, err := uc.Execute(context.Background(), uuid.New(), uuid.Nil) + + require.NoError(t, err) + assert.Equal(t, 4, res.TotalControls) + assert.Equal(t, 2, res.TotalGaps, "in_progress + not_implemented are gaps; implemented and not_applicable are not") + require.Len(t, res.Frameworks, 1) + assert.Equal(t, 1, res.Frameworks[0].Implemented) + assert.Equal(t, 1, res.Frameworks[0].InProgress) + assert.Equal(t, 1, res.Frameworks[0].NotImplemented) + assert.Equal(t, 1, res.Frameworks[0].NotApplicable) + assert.Equal(t, 2, res.Frameworks[0].Gaps) + // 1 implemented of 3 applicable ≈ 33.3% + assert.InDelta(t, 33.333, res.Frameworks[0].PercentComplete, 0.01) + require.Len(t, res.Gaps, 2) + // Evidence count is carried onto the gap entry. + for _, g := range res.Gaps { + if g.ControlID == c2 { + assert.Equal(t, 1, g.EvidenceCount) + } + } +} + +// TestGetGapAnalysis_SingleFramework scopes to one framework when a frameworkID is given. +func TestGetGapAnalysis_SingleFramework(t *testing.T) { + fwID := uuid.New() + repo := &MockComplianceRepository{ + getFrameworkByIDFunc: func(ctx context.Context, id, tid uuid.UUID) (*domain.ComplianceFramework, error) { + return &domain.ComplianceFramework{ID: fwID, Name: "DORA"}, nil + }, + listControlsByFrameworkFunc: func(ctx context.Context, tid, fid uuid.UUID) ([]domain.ComplianceControl, error) { + return []domain.ComplianceControl{{ID: uuid.New(), Status: domain.ControlStatusNotImplemented}}, nil + }, + countEvidencesByFwFunc: func(ctx context.Context, tid, fid uuid.UUID) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + res, err := uc.Execute(context.Background(), uuid.New(), fwID) + + require.NoError(t, err) + require.Len(t, res.Frameworks, 1) + assert.Equal(t, 1, res.TotalGaps) +} + +// TestGetGapAnalysis_UnknownFramework returns ErrNotFound (never another tenant's data). +func TestGetGapAnalysis_UnknownFramework(t *testing.T) { + repo := &MockComplianceRepository{ + getFrameworkByIDFunc: func(ctx context.Context, id, tid uuid.UUID) (*domain.ComplianceFramework, error) { + return nil, nil // not found or belongs to another tenant + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + _, err := uc.Execute(context.Background(), uuid.New(), uuid.New()) + + assert.ErrorIs(t, err, domain.ErrNotFound) +} diff --git a/backend/internal/application/complianceaudit/audit_usecases.go b/backend/internal/application/complianceaudit/audit_usecases.go new file mode 100644 index 00000000..69a0113a --- /dev/null +++ b/backend/internal/application/complianceaudit/audit_usecases.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +// Package complianceaudit holds the use cases for compliance audits (plan, +// execute, archive) and remediation plans (create, assign, track). Each use case +// is a small injectable struct; CRUD for one aggregate is grouped per file for +// readability. Every method is tenant-scoped and returns typed domain errors. +package complianceaudit + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// -------------------- Create -------------------- + +// CreateAuditInput is the payload to schedule a new audit. +type CreateAuditInput struct { + Title string + FrameworkID *uuid.UUID + Type string + Auditor string + Scope string + ScheduledStart *time.Time + ScheduledEnd *time.Time +} + +type CreateAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewCreateAuditUseCase(repo domain.ComplianceAuditRepository) *CreateAuditUseCase { + return &CreateAuditUseCase{repo: repo} +} + +func (uc *CreateAuditUseCase) Execute(ctx context.Context, tenantID, createdBy uuid.UUID, in CreateAuditInput) (*domain.ComplianceAudit, error) { + title := strings.TrimSpace(in.Title) + if title == "" { + return nil, domain.NewValidationError("title is required") + } + auditType, err := domain.ParseAuditType(in.Type) + if err != nil { + return nil, err + } + + audit := &domain.ComplianceAudit{ + TenantID: tenantID, + Title: title, + FrameworkID: in.FrameworkID, + Type: auditType, + Status: domain.AuditStatusPlanned, + Auditor: strings.TrimSpace(in.Auditor), + Scope: in.Scope, + ScheduledStart: in.ScheduledStart, + ScheduledEnd: in.ScheduledEnd, + } + if createdBy != uuid.Nil { + audit.CreatedBy = &createdBy + } + if err := uc.repo.CreateAudit(ctx, audit); err != nil { + return nil, err + } + return audit, nil +} + +// -------------------- List -------------------- + +type ListAuditsUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewListAuditsUseCase(repo domain.ComplianceAuditRepository) *ListAuditsUseCase { + return &ListAuditsUseCase{repo: repo} +} + +func (uc *ListAuditsUseCase) Execute(ctx context.Context, tenantID uuid.UUID) ([]domain.ComplianceAudit, error) { + return uc.repo.ListAudits(ctx, tenantID) +} + +// -------------------- Get -------------------- + +type GetAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewGetAuditUseCase(repo domain.ComplianceAuditRepository) *GetAuditUseCase { + return &GetAuditUseCase{repo: repo} +} + +func (uc *GetAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) (*domain.ComplianceAudit, error) { + audit, err := uc.repo.GetAuditByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if audit == nil { + return nil, domain.NewNotFoundError("audit", id) + } + return audit, nil +} + +// -------------------- Update -------------------- + +// UpdateAuditInput carries the editable fields. Nil pointers leave a field +// unchanged; Status is applied as-is (empty keeps the current status). +type UpdateAuditInput struct { + Title *string + FrameworkID *uuid.UUID + ClearFramework bool // set FrameworkID back to nil (program-wide) + Type *string + Status *string + Auditor *string + Scope *string + Summary *string + ComplianceScore *float64 + ScheduledStart *time.Time + ScheduledEnd *time.Time +} + +type UpdateAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewUpdateAuditUseCase(repo domain.ComplianceAuditRepository) *UpdateAuditUseCase { + return &UpdateAuditUseCase{repo: repo} +} + +func (uc *UpdateAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID, in UpdateAuditInput) (*domain.ComplianceAudit, error) { + audit, err := uc.repo.GetAuditByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if audit == nil { + return nil, domain.NewNotFoundError("audit", id) + } + + if in.Title != nil { + t := strings.TrimSpace(*in.Title) + if t == "" { + return nil, domain.NewValidationError("title cannot be empty") + } + audit.Title = t + } + if in.ClearFramework { + audit.FrameworkID = nil + } else if in.FrameworkID != nil { + audit.FrameworkID = in.FrameworkID + } + if in.Type != nil { + at, err := domain.ParseAuditType(*in.Type) + if err != nil { + return nil, err + } + audit.Type = at + } + if in.Status != nil { + st, err := domain.ParseAuditStatus(*in.Status) + if err != nil { + return nil, err + } + audit.Status = st + // Completing the audit stamps CompletedAt once; reopening clears it. + if st == domain.AuditStatusCompleted && audit.CompletedAt == nil { + now := time.Now().UTC() + audit.CompletedAt = &now + } + if st != domain.AuditStatusCompleted { + audit.CompletedAt = nil + } + } + if in.Auditor != nil { + audit.Auditor = strings.TrimSpace(*in.Auditor) + } + if in.Scope != nil { + audit.Scope = *in.Scope + } + if in.Summary != nil { + audit.Summary = *in.Summary + } + if in.ComplianceScore != nil { + audit.ComplianceScore = *in.ComplianceScore + } + if in.ScheduledStart != nil { + audit.ScheduledStart = in.ScheduledStart + } + if in.ScheduledEnd != nil { + audit.ScheduledEnd = in.ScheduledEnd + } + + if err := uc.repo.UpdateAudit(ctx, audit); err != nil { + return nil, err + } + return audit, nil +} + +// -------------------- Delete -------------------- + +type DeleteAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewDeleteAuditUseCase(repo domain.ComplianceAuditRepository) *DeleteAuditUseCase { + return &DeleteAuditUseCase{repo: repo} +} + +func (uc *DeleteAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error { + return uc.repo.DeleteAudit(ctx, id, tenantID) +} diff --git a/backend/internal/application/complianceaudit/generate_remediations.go b/backend/internal/application/complianceaudit/generate_remediations.go new file mode 100644 index 00000000..ff314ec1 --- /dev/null +++ b/backend/internal/application/complianceaudit/generate_remediations.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package complianceaudit + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// GenerateRemediationsResult reports what an auto-generation run produced. +type GenerateRemediationsResult struct { + Created int `json:"created"` // new plans opened + Skipped int `json:"skipped"` // gaps that already had an active plan + Plans []domain.RemediationPlan `json:"plans"` // the newly created plans +} + +// GenerateRemediationsFromAuditUseCase turns the gaps found under an audit's +// framework into remediation plans in one action — the natural next step once an +// audit is completed. It is idempotent: a gap that already has a non-cancelled +// remediation plan is skipped, so re-running never duplicates. +type GenerateRemediationsFromAuditUseCase struct { + auditRepo domain.ComplianceAuditRepository + remediationRepo domain.RemediationPlanRepository + complianceRepo domain.ComplianceRepository +} + +func NewGenerateRemediationsFromAuditUseCase( + auditRepo domain.ComplianceAuditRepository, + remediationRepo domain.RemediationPlanRepository, + complianceRepo domain.ComplianceRepository, +) *GenerateRemediationsFromAuditUseCase { + return &GenerateRemediationsFromAuditUseCase{auditRepo: auditRepo, remediationRepo: remediationRepo, complianceRepo: complianceRepo} +} + +// isAuditGap mirrors the gap-analysis rule: not implemented and not out of scope. +func isAuditGap(s domain.ControlStatus) bool { + return s != domain.ControlStatusImplemented && s != domain.ControlStatusNotApplicable +} + +func (uc *GenerateRemediationsFromAuditUseCase) Execute(ctx context.Context, tenantID, auditID, createdBy uuid.UUID) (*GenerateRemediationsResult, error) { + audit, err := uc.auditRepo.GetAuditByID(ctx, auditID, tenantID) + if err != nil { + return nil, err + } + if audit == nil { + return nil, domain.NewNotFoundError("audit", auditID) + } + if audit.FrameworkID == nil { + return nil, domain.NewValidationError("this audit is program-wide (no framework) — scope it to a framework to generate remediation plans") + } + frameworkID := *audit.FrameworkID + + controls, err := uc.complianceRepo.ListControlsByFramework(ctx, tenantID, frameworkID) + if err != nil { + return nil, err + } + + // Existing plans for this framework — skip a gap that already has a + // non-cancelled plan so re-running is idempotent. + existing, err := uc.remediationRepo.ListRemediations(ctx, tenantID, domain.RemediationFilter{FrameworkID: &frameworkID}) + if err != nil { + return nil, err + } + activeByControl := map[uuid.UUID]bool{} + for _, p := range existing { + if p.ControlID != nil && p.Status != domain.RemediationStatusCancelled { + activeByControl[*p.ControlID] = true + } + } + + result := &GenerateRemediationsResult{Plans: make([]domain.RemediationPlan, 0)} + for i := range controls { + c := controls[i] + if !isAuditGap(c.Status) { + continue + } + if activeByControl[c.ID] { + result.Skipped++ + continue + } + + controlID := c.ID + plan := &domain.RemediationPlan{ + TenantID: tenantID, + Title: fmt.Sprintf("%s — %s", c.ReferenceCode, c.Name), + Description: fmt.Sprintf("Auto-généré depuis l'audit « %s ». Remédier l'écart sur le contrôle %s.", audit.Title, c.ReferenceCode), + ControlID: &controlID, + FrameworkID: &frameworkID, + AuditID: &auditID, + Priority: priorityForStatus(c.Status), + Status: domain.RemediationStatusOpen, + } + if createdBy != uuid.Nil { + plan.CreatedBy = &createdBy + } + if err := uc.remediationRepo.CreateRemediation(ctx, plan); err != nil { + return nil, err + } + result.Created++ + result.Plans = append(result.Plans, *plan) + } + return result, nil +} + +// priorityForStatus derives a starting priority from the control's gap state: +// a control not started at all is more urgent than one already in progress. +func priorityForStatus(s domain.ControlStatus) domain.RemediationPriority { + if s == domain.ControlStatusInProgress { + return domain.RemediationPriorityMedium + } + return domain.RemediationPriorityHigh +} diff --git a/backend/internal/application/complianceaudit/generate_remediations_test.go b/backend/internal/application/complianceaudit/generate_remediations_test.go new file mode 100644 index 00000000..83072276 --- /dev/null +++ b/backend/internal/application/complianceaudit/generate_remediations_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package complianceaudit + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- compact mocks (only the methods the use case touches carry behaviour) --- + +type mockAuditRepo struct { + audit *domain.ComplianceAudit +} + +func (m *mockAuditRepo) CreateAudit(context.Context, *domain.ComplianceAudit) error { return nil } +func (m *mockAuditRepo) GetAuditByID(_ context.Context, _, _ uuid.UUID) (*domain.ComplianceAudit, error) { + return m.audit, nil +} +func (m *mockAuditRepo) ListAudits(context.Context, uuid.UUID) ([]domain.ComplianceAudit, error) { + return nil, nil +} +func (m *mockAuditRepo) UpdateAudit(context.Context, *domain.ComplianceAudit) error { return nil } +func (m *mockAuditRepo) DeleteAudit(context.Context, uuid.UUID, uuid.UUID) error { return nil } + +type mockRemediationRepo struct { + existing []domain.RemediationPlan + created []domain.RemediationPlan +} + +func (m *mockRemediationRepo) CreateRemediation(_ context.Context, r *domain.RemediationPlan) error { + m.created = append(m.created, *r) + return nil +} +func (m *mockRemediationRepo) GetRemediationByID(context.Context, uuid.UUID, uuid.UUID) (*domain.RemediationPlan, error) { + return nil, nil +} +func (m *mockRemediationRepo) ListRemediations(context.Context, uuid.UUID, domain.RemediationFilter) ([]domain.RemediationPlan, error) { + return m.existing, nil +} +func (m *mockRemediationRepo) UpdateRemediation(context.Context, *domain.RemediationPlan) error { return nil } +func (m *mockRemediationRepo) DeleteRemediation(context.Context, uuid.UUID, uuid.UUID) error { return nil } + +// mockComplianceRepo stubs the whole port; only ListControlsByFramework carries data. +type mockComplianceRepo struct { + controls []domain.ComplianceControl +} + +func (m *mockComplianceRepo) CreateFramework(context.Context, *domain.ComplianceFramework) error { return nil } +func (m *mockComplianceRepo) GetFrameworkByID(context.Context, uuid.UUID, uuid.UUID) (*domain.ComplianceFramework, error) { + return nil, nil +} +func (m *mockComplianceRepo) ListFrameworks(context.Context, uuid.UUID) ([]domain.ComplianceFramework, error) { + return nil, nil +} +func (m *mockComplianceRepo) DeleteFramework(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (m *mockComplianceRepo) CreateControl(context.Context, *domain.ComplianceControl) error { return nil } +func (m *mockComplianceRepo) GetControlByID(context.Context, uuid.UUID, uuid.UUID) (*domain.ComplianceControl, error) { + return nil, nil +} +func (m *mockComplianceRepo) ListControlsByFramework(context.Context, uuid.UUID, uuid.UUID) ([]domain.ComplianceControl, error) { + return m.controls, nil +} +func (m *mockComplianceRepo) UpdateControl(context.Context, *domain.ComplianceControl) error { return nil } +func (m *mockComplianceRepo) DeleteControl(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (m *mockComplianceRepo) DeleteControlsByFramework(context.Context, uuid.UUID, uuid.UUID) (int64, error) { + return 0, nil +} +func (m *mockComplianceRepo) CreateEvidence(context.Context, *domain.ControlEvidence) error { return nil } +func (m *mockComplianceRepo) GetEvidenceByID(context.Context, uuid.UUID, uuid.UUID) (*domain.ControlEvidence, error) { + return nil, nil +} +func (m *mockComplianceRepo) ListEvidencesByControl(context.Context, uuid.UUID, uuid.UUID) ([]domain.ControlEvidence, error) { + return nil, nil +} +func (m *mockComplianceRepo) CountEvidencesByFramework(context.Context, uuid.UUID, uuid.UUID) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil +} +func (m *mockComplianceRepo) DeleteEvidence(context.Context, uuid.UUID, uuid.UUID) error { return nil } + +// --- tests --- + +func TestGenerateRemediations_CreatesForGapsOnly(t *testing.T) { + fwID := uuid.New() + c1, c2, c3, c4 := uuid.New(), uuid.New(), uuid.New(), uuid.New() + auditRepo := &mockAuditRepo{audit: &domain.ComplianceAudit{ID: uuid.New(), FrameworkID: &fwID, Title: "Audit Q3"}} + compRepo := &mockComplianceRepo{controls: []domain.ComplianceControl{ + {ID: c1, FrameworkID: fwID, ReferenceCode: "A.1", Status: domain.ControlStatusNotImplemented}, + {ID: c2, FrameworkID: fwID, ReferenceCode: "A.2", Status: domain.ControlStatusInProgress}, + {ID: c3, FrameworkID: fwID, ReferenceCode: "A.3", Status: domain.ControlStatusImplemented}, // not a gap + {ID: c4, FrameworkID: fwID, ReferenceCode: "A.4", Status: domain.ControlStatusNotApplicable}, // not a gap + }} + remRepo := &mockRemediationRepo{} + uc := NewGenerateRemediationsFromAuditUseCase(auditRepo, remRepo, compRepo) + + res, err := uc.Execute(context.Background(), uuid.New(), auditRepo.audit.ID, uuid.New()) + + require.NoError(t, err) + assert.Equal(t, 2, res.Created, "only the not_implemented + in_progress controls are gaps") + assert.Equal(t, 0, res.Skipped) + require.Len(t, remRepo.created, 2) + // not_implemented → high, in_progress → medium + got := map[string]domain.RemediationPriority{} + for _, p := range remRepo.created { + got[p.Title[:3]] = p.Priority + } + assert.Equal(t, domain.RemediationPriorityHigh, got["A.1"]) + assert.Equal(t, domain.RemediationPriorityMedium, got["A.2"]) +} + +func TestGenerateRemediations_SkipsControlsWithActivePlan(t *testing.T) { + fwID := uuid.New() + c1 := uuid.New() + auditRepo := &mockAuditRepo{audit: &domain.ComplianceAudit{ID: uuid.New(), FrameworkID: &fwID}} + compRepo := &mockComplianceRepo{controls: []domain.ComplianceControl{ + {ID: c1, FrameworkID: fwID, ReferenceCode: "A.1", Status: domain.ControlStatusNotImplemented}, + }} + remRepo := &mockRemediationRepo{existing: []domain.RemediationPlan{ + {ID: uuid.New(), ControlID: &c1, Status: domain.RemediationStatusOpen}, + }} + uc := NewGenerateRemediationsFromAuditUseCase(auditRepo, remRepo, compRepo) + + res, err := uc.Execute(context.Background(), uuid.New(), auditRepo.audit.ID, uuid.Nil) + + require.NoError(t, err) + assert.Equal(t, 0, res.Created) + assert.Equal(t, 1, res.Skipped, "control already has an active plan") +} + +func TestGenerateRemediations_ProgramWideAuditRejected(t *testing.T) { + auditRepo := &mockAuditRepo{audit: &domain.ComplianceAudit{ID: uuid.New(), FrameworkID: nil}} + uc := NewGenerateRemediationsFromAuditUseCase(auditRepo, &mockRemediationRepo{}, &mockComplianceRepo{}) + _, err := uc.Execute(context.Background(), uuid.New(), auditRepo.audit.ID, uuid.Nil) + assert.ErrorIs(t, err, domain.ErrValidation) +} + +func TestGenerateRemediations_UnknownAudit404(t *testing.T) { + uc := NewGenerateRemediationsFromAuditUseCase(&mockAuditRepo{audit: nil}, &mockRemediationRepo{}, &mockComplianceRepo{}) + _, err := uc.Execute(context.Background(), uuid.New(), uuid.New(), uuid.Nil) + assert.ErrorIs(t, err, domain.ErrNotFound) +} diff --git a/backend/internal/application/complianceaudit/remediation_usecases.go b/backend/internal/application/complianceaudit/remediation_usecases.go new file mode 100644 index 00000000..20832bc3 --- /dev/null +++ b/backend/internal/application/complianceaudit/remediation_usecases.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package complianceaudit + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// -------------------- Create -------------------- + +// CreateRemediationInput is the payload to open a remediation plan against a +// compliance gap. +type CreateRemediationInput struct { + Title string + Description string + ControlID *uuid.UUID + AuditID *uuid.UUID + Priority string + AssignedTo *uuid.UUID + DueDate *time.Time +} + +// CreateRemediationUseCase opens a remediation plan. It depends on the compliance +// repository too, so it can validate the linked control belongs to the tenant +// (double cross-tenant guard) and derive the framework from it. +type CreateRemediationUseCase struct { + repo domain.RemediationPlanRepository + complianceRepo domain.ComplianceRepository +} + +func NewCreateRemediationUseCase(repo domain.RemediationPlanRepository, complianceRepo domain.ComplianceRepository) *CreateRemediationUseCase { + return &CreateRemediationUseCase{repo: repo, complianceRepo: complianceRepo} +} + +func (uc *CreateRemediationUseCase) Execute(ctx context.Context, tenantID, createdBy uuid.UUID, in CreateRemediationInput) (*domain.RemediationPlan, error) { + title := strings.TrimSpace(in.Title) + if title == "" { + return nil, domain.NewValidationError("title is required") + } + priority, err := domain.ParseRemediationPriority(in.Priority) + if err != nil { + return nil, err + } + + plan := &domain.RemediationPlan{ + TenantID: tenantID, + Title: title, + Description: in.Description, + ControlID: in.ControlID, + AuditID: in.AuditID, + Priority: priority, + Status: domain.RemediationStatusOpen, + AssignedTo: in.AssignedTo, + DueDate: in.DueDate, + } + if createdBy != uuid.Nil { + plan.CreatedBy = &createdBy + } + + // If linked to a control, verify it exists for THIS tenant and derive the + // framework from it. GetControlByID returns (nil, nil) for another tenant's + // control — so a forged control_id can never attach across tenants. + if in.ControlID != nil { + ctrl, err := uc.complianceRepo.GetControlByID(ctx, *in.ControlID, tenantID) + if err != nil { + return nil, err + } + if ctrl == nil { + return nil, domain.NewValidationError("linked control not found") + } + plan.FrameworkID = &ctrl.FrameworkID + } + + if err := uc.repo.CreateRemediation(ctx, plan); err != nil { + return nil, err + } + return plan, nil +} + +// -------------------- List -------------------- + +type ListRemediationsUseCase struct { + repo domain.RemediationPlanRepository + complianceRepo domain.ComplianceRepository +} + +func NewListRemediationsUseCase(repo domain.RemediationPlanRepository, complianceRepo domain.ComplianceRepository) *ListRemediationsUseCase { + return &ListRemediationsUseCase{repo: repo, complianceRepo: complianceRepo} +} + +func (uc *ListRemediationsUseCase) Execute(ctx context.Context, tenantID uuid.UUID, filter domain.RemediationFilter) ([]domain.RemediationPlan, error) { + plans, err := uc.repo.ListRemediations(ctx, tenantID, filter) + if err != nil { + return nil, err + } + // Enrich each plan with its linked control's code/name for a readable UI. + // Cached per control id so repeated links don't re-query. Lists are small + // (remediation plans per tenant), so this bounded lookup is cheap. + cache := map[uuid.UUID]*domain.ComplianceControl{} + for i := range plans { + if plans[i].ControlID == nil { + continue + } + cid := *plans[i].ControlID + ctrl, ok := cache[cid] + if !ok { + ctrl, err = uc.complianceRepo.GetControlByID(ctx, cid, tenantID) + if err != nil { + return nil, err + } + cache[cid] = ctrl + } + if ctrl != nil { + plans[i].ControlCode = ctrl.ReferenceCode + plans[i].ControlName = ctrl.Name + } + } + return plans, nil +} + +// -------------------- Update -------------------- + +// UpdateRemediationInput carries editable fields; nil leaves a field unchanged. +type UpdateRemediationInput struct { + Title *string + Description *string + Priority *string + Status *string + AssignedTo *uuid.UUID + ClearAssignee bool + DueDate *time.Time + ClearDueDate bool +} + +type UpdateRemediationUseCase struct{ repo domain.RemediationPlanRepository } + +func NewUpdateRemediationUseCase(repo domain.RemediationPlanRepository) *UpdateRemediationUseCase { + return &UpdateRemediationUseCase{repo: repo} +} + +func (uc *UpdateRemediationUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID, in UpdateRemediationInput) (*domain.RemediationPlan, error) { + plan, err := uc.repo.GetRemediationByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if plan == nil { + return nil, domain.NewNotFoundError("remediation plan", id) + } + + if in.Title != nil { + t := strings.TrimSpace(*in.Title) + if t == "" { + return nil, domain.NewValidationError("title cannot be empty") + } + plan.Title = t + } + if in.Description != nil { + plan.Description = *in.Description + } + if in.Priority != nil { + p, err := domain.ParseRemediationPriority(*in.Priority) + if err != nil { + return nil, err + } + plan.Priority = p + } + if in.Status != nil { + s, err := domain.ParseRemediationStatus(*in.Status) + if err != nil { + return nil, err + } + plan.Status = s + if s == domain.RemediationStatusCompleted && plan.CompletedAt == nil { + now := time.Now().UTC() + plan.CompletedAt = &now + } + if s != domain.RemediationStatusCompleted { + plan.CompletedAt = nil + } + } + if in.ClearAssignee { + plan.AssignedTo = nil + } else if in.AssignedTo != nil { + plan.AssignedTo = in.AssignedTo + } + if in.ClearDueDate { + plan.DueDate = nil + } else if in.DueDate != nil { + plan.DueDate = in.DueDate + } + + if err := uc.repo.UpdateRemediation(ctx, plan); err != nil { + return nil, err + } + return plan, nil +} + +// -------------------- Delete -------------------- + +type DeleteRemediationUseCase struct{ repo domain.RemediationPlanRepository } + +func NewDeleteRemediationUseCase(repo domain.RemediationPlanRepository) *DeleteRemediationUseCase { + return &DeleteRemediationUseCase{repo: repo} +} + +func (uc *DeleteRemediationUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error { + return uc.repo.DeleteRemediation(ctx, id, tenantID) +} diff --git a/backend/internal/domain/compliance_audit.go b/backend/internal/domain/compliance_audit.go new file mode 100644 index 00000000..56995c05 --- /dev/null +++ b/backend/internal/domain/compliance_audit.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package domain + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ============================================================================= +// Compliance audits ("Audits" — planification, exécution, historique) +// ============================================================================= + +// AuditType is the nature of a compliance audit. +type AuditType string + +const ( + AuditTypeInternal AuditType = "internal" // self-assessment + AuditTypeExternal AuditType = "external" // third-party audit + AuditTypeCertification AuditType = "certification" // certification body audit (e.g. ISO 27001 stage 2) + AuditTypeSurveillance AuditType = "surveillance" // periodic surveillance audit +) + +// ParseAuditType validates an audit type (empty → internal). +func ParseAuditType(s string) (AuditType, error) { + if s == "" { + return AuditTypeInternal, nil + } + switch AuditType(s) { + case AuditTypeInternal, AuditTypeExternal, AuditTypeCertification, AuditTypeSurveillance: + return AuditType(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid audit type: %q", s)) + } +} + +// AuditStatus is the lifecycle state of a compliance audit. +type AuditStatus string + +const ( + AuditStatusPlanned AuditStatus = "planned" + AuditStatusInProgress AuditStatus = "in_progress" + AuditStatusCompleted AuditStatus = "completed" + AuditStatusCancelled AuditStatus = "cancelled" +) + +// ParseAuditStatus validates an audit status (empty → planned). +func ParseAuditStatus(s string) (AuditStatus, error) { + if s == "" { + return AuditStatusPlanned, nil + } + switch AuditStatus(s) { + case AuditStatusPlanned, AuditStatusInProgress, AuditStatusCompleted, AuditStatusCancelled: + return AuditStatus(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid audit status: %q", s)) + } +} + +// ComplianceAudit is a tenant-scoped audit: planned, executed, then archived as +// history. It may target a single framework (FrameworkID set) or the whole +// compliance program (FrameworkID nil). +type ComplianceAudit struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` + + Title string `gorm:"size:255;not null" json:"title"` + FrameworkID *uuid.UUID `gorm:"type:uuid;index" json:"framework_id"` // nil = program-wide + Type AuditType `gorm:"type:varchar(24);not null;default:'internal'" json:"type"` + Status AuditStatus `gorm:"type:varchar(24);not null;default:'planned';index" json:"status"` + + Auditor string `gorm:"size:255" json:"auditor"` // auditor name or firm + Scope string `gorm:"type:text" json:"scope"` + Summary string `gorm:"type:text" json:"summary"` // conclusions / findings summary + + // ComplianceScore is an optional posture snapshot (0–100) recorded when the + // audit is completed. + ComplianceScore float64 `gorm:"type:numeric(5,2)" json:"compliance_score"` + + ScheduledStart *time.Time `json:"scheduled_start"` + ScheduledEnd *time.Time `json:"scheduled_end"` + CompletedAt *time.Time `json:"completed_at"` + + CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (ComplianceAudit) TableName() string { return "compliance_audits" } + +// ComplianceAuditRepository is the port for audit persistence. Every method is +// tenant-scoped: a resource belonging to another tenant is returned as not found. +type ComplianceAuditRepository interface { + CreateAudit(ctx context.Context, a *ComplianceAudit) error + GetAuditByID(ctx context.Context, id, tenantID uuid.UUID) (*ComplianceAudit, error) + ListAudits(ctx context.Context, tenantID uuid.UUID) ([]ComplianceAudit, error) + UpdateAudit(ctx context.Context, a *ComplianceAudit) error + DeleteAudit(ctx context.Context, id, tenantID uuid.UUID) error +} + +// ============================================================================= +// Remediation plans ("Plans de remédiation") +// ============================================================================= + +// RemediationPriority ranks the urgency of a remediation action. +type RemediationPriority string + +const ( + RemediationPriorityLow RemediationPriority = "low" + RemediationPriorityMedium RemediationPriority = "medium" + RemediationPriorityHigh RemediationPriority = "high" + RemediationPriorityCritical RemediationPriority = "critical" +) + +// ParseRemediationPriority validates a priority (empty → medium). +func ParseRemediationPriority(s string) (RemediationPriority, error) { + if s == "" { + return RemediationPriorityMedium, nil + } + switch RemediationPriority(s) { + case RemediationPriorityLow, RemediationPriorityMedium, RemediationPriorityHigh, RemediationPriorityCritical: + return RemediationPriority(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid remediation priority: %q", s)) + } +} + +// RemediationStatus is the lifecycle state of a remediation plan. +type RemediationStatus string + +const ( + RemediationStatusOpen RemediationStatus = "open" + RemediationStatusInProgress RemediationStatus = "in_progress" + RemediationStatusCompleted RemediationStatus = "completed" + RemediationStatusCancelled RemediationStatus = "cancelled" +) + +// ParseRemediationStatus validates a status (empty → open). +func ParseRemediationStatus(s string) (RemediationStatus, error) { + if s == "" { + return RemediationStatusOpen, nil + } + switch RemediationStatus(s) { + case RemediationStatusOpen, RemediationStatusInProgress, RemediationStatusCompleted, RemediationStatusCancelled: + return RemediationStatus(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid remediation status: %q", s)) + } +} + +// RemediationPlan is a tenant-scoped action to close a compliance gap. It is +// linked to the control it remediates (ControlID) and, optionally, to the audit +// that surfaced the gap (AuditID). This is compliance-specific remediation, +// distinct from risk Mitigations (which hang off a Risk). +type RemediationPlan struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` + + Title string `gorm:"size:255;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + + ControlID *uuid.UUID `gorm:"type:uuid;index" json:"control_id"` // the gap being remediated + FrameworkID *uuid.UUID `gorm:"type:uuid;index" json:"framework_id"` // denormalised for filtering + AuditID *uuid.UUID `gorm:"type:uuid;index" json:"audit_id"` // origin audit, if any + + Priority RemediationPriority `gorm:"type:varchar(16);not null;default:'medium'" json:"priority"` + Status RemediationStatus `gorm:"type:varchar(24);not null;default:'open';index" json:"status"` + + AssignedTo *uuid.UUID `gorm:"type:uuid;index" json:"assigned_to"` + DueDate *time.Time `json:"due_date"` + CompletedAt *time.Time `json:"completed_at"` + + CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Computed, NOT persisted — the linked control's reference code/name, filled + // by the list use case for a readable UI without an extra round-trip. + ControlCode string `gorm:"-" json:"control_code,omitempty"` + ControlName string `gorm:"-" json:"control_name,omitempty"` +} + +func (RemediationPlan) TableName() string { return "remediation_plans" } + +// RemediationFilter narrows a remediation list query. Zero-value fields are +// ignored (no filter on that dimension). +type RemediationFilter struct { + ControlID *uuid.UUID + FrameworkID *uuid.UUID + AuditID *uuid.UUID + Status RemediationStatus +} + +// RemediationPlanRepository is the port for remediation-plan persistence. +// Tenant-scoped throughout. +type RemediationPlanRepository interface { + CreateRemediation(ctx context.Context, r *RemediationPlan) error + GetRemediationByID(ctx context.Context, id, tenantID uuid.UUID) (*RemediationPlan, error) + ListRemediations(ctx context.Context, tenantID uuid.UUID, filter RemediationFilter) ([]RemediationPlan, error) + UpdateRemediation(ctx context.Context, r *RemediationPlan) error + DeleteRemediation(ctx context.Context, id, tenantID uuid.UUID) error +} diff --git a/backend/internal/domain/control_mapping.go b/backend/internal/domain/control_mapping.go new file mode 100644 index 00000000..202abca5 --- /dev/null +++ b/backend/internal/domain/control_mapping.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package domain + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// MappingRelation qualifies how strongly two controls correspond across +// frameworks — a crosswalk ("cross-mapping") between e.g. ISO 27001 A.5.1 and +// NIST CSF GV.PO. +type MappingRelation string + +const ( + MappingRelationEquivalent MappingRelation = "equivalent" // the two controls demand essentially the same thing + MappingRelationPartial MappingRelation = "partial" // overlapping but not fully equivalent + MappingRelationRelated MappingRelation = "related" // thematically related +) + +// ParseMappingRelation validates a relation (empty → equivalent). +func ParseMappingRelation(s string) (MappingRelation, error) { + if s == "" { + return MappingRelationEquivalent, nil + } + switch MappingRelation(s) { + case MappingRelationEquivalent, MappingRelationPartial, MappingRelationRelated: + return MappingRelation(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid mapping relation: %q", s)) + } +} + +// ControlMapping is a tenant-scoped, undirected crosswalk between two compliance +// controls that (normally) live in different frameworks. It lets the product show +// that satisfying one control corresponds to coverage in another framework. +// +// The pair is stored order-independent-safe: the repository refuses a duplicate +// in either direction (A→B and B→A are the same mapping). +type ControlMapping struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` + + SourceControlID uuid.UUID `gorm:"type:uuid;not null;index" json:"source_control_id"` + TargetControlID uuid.UUID `gorm:"type:uuid;not null;index" json:"target_control_id"` + + Relation MappingRelation `gorm:"type:varchar(16);not null;default:'equivalent'" json:"relation"` + Note string `gorm:"type:text" json:"note"` + + CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Computed, NOT persisted — filled by the list use case so the UI can render + // each side without extra round-trips. + SourceCode string `gorm:"-" json:"source_code,omitempty"` + SourceName string `gorm:"-" json:"source_name,omitempty"` + SourceFrameworkID string `gorm:"-" json:"source_framework_id,omitempty"` + SourceFrameworkName string `gorm:"-" json:"source_framework_name,omitempty"` + TargetCode string `gorm:"-" json:"target_code,omitempty"` + TargetName string `gorm:"-" json:"target_name,omitempty"` + TargetFrameworkID string `gorm:"-" json:"target_framework_id,omitempty"` + TargetFrameworkName string `gorm:"-" json:"target_framework_name,omitempty"` +} + +func (ControlMapping) TableName() string { return "control_mappings" } + +// ControlMappingRepository is the port for crosswalk persistence. Tenant-scoped +// throughout; a mapping owned by another tenant reads back as not found. +type ControlMappingRepository interface { + Create(ctx context.Context, m *ControlMapping) error + // Exists reports whether a mapping between the two controls already exists in + // EITHER direction for the tenant. + Exists(ctx context.Context, tenantID, a, b uuid.UUID) (bool, error) + // List returns every mapping for the tenant. If controlID is non-nil, only + // mappings touching that control (as source OR target) are returned. + List(ctx context.Context, tenantID uuid.UUID, controlID *uuid.UUID) ([]ControlMapping, error) + Delete(ctx context.Context, id, tenantID uuid.UUID) error +} diff --git a/backend/internal/handler/compliance_audit_handler.go b/backend/internal/handler/compliance_audit_handler.go new file mode 100644 index 00000000..5af2be63 --- /dev/null +++ b/backend/internal/handler/compliance_audit_handler.go @@ -0,0 +1,389 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package handler + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/application/complianceaudit" + "github.com/opendefender/openrisk/internal/domain" +) + +// ComplianceAuditHandler exposes the compliance audit + remediation-plan use +// cases. Tenant/user come from middleware.GetContext via the shared tenantID()/ +// userID() helpers (defined in compliance_handler.go). +type ComplianceAuditHandler struct { + createAudit *complianceaudit.CreateAuditUseCase + listAudits *complianceaudit.ListAuditsUseCase + getAudit *complianceaudit.GetAuditUseCase + updateAudit *complianceaudit.UpdateAuditUseCase + deleteAudit *complianceaudit.DeleteAuditUseCase + + createRemediation *complianceaudit.CreateRemediationUseCase + listRemediations *complianceaudit.ListRemediationsUseCase + updateRemediation *complianceaudit.UpdateRemediationUseCase + deleteRemediation *complianceaudit.DeleteRemediationUseCase + + generateRemediations *complianceaudit.GenerateRemediationsFromAuditUseCase +} + +func NewComplianceAuditHandler( + createAudit *complianceaudit.CreateAuditUseCase, + listAudits *complianceaudit.ListAuditsUseCase, + getAudit *complianceaudit.GetAuditUseCase, + updateAudit *complianceaudit.UpdateAuditUseCase, + deleteAudit *complianceaudit.DeleteAuditUseCase, + createRemediation *complianceaudit.CreateRemediationUseCase, + listRemediations *complianceaudit.ListRemediationsUseCase, + updateRemediation *complianceaudit.UpdateRemediationUseCase, + deleteRemediation *complianceaudit.DeleteRemediationUseCase, + generateRemediations *complianceaudit.GenerateRemediationsFromAuditUseCase, +) *ComplianceAuditHandler { + return &ComplianceAuditHandler{ + createAudit: createAudit, listAudits: listAudits, getAudit: getAudit, + updateAudit: updateAudit, deleteAudit: deleteAudit, + createRemediation: createRemediation, listRemediations: listRemediations, + updateRemediation: updateRemediation, deleteRemediation: deleteRemediation, + generateRemediations: generateRemediations, + } +} + +// parseOptionalDate accepts RFC3339 or a plain YYYY-MM-DD date; "" → nil. +func parseOptionalDate(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return &t, nil + } + if t, err := time.Parse("2006-01-02", s); err == nil { + return &t, nil + } + return nil, domain.NewValidationError("invalid date: " + s) +} + +// parseOptionalUUID parses a non-empty uuid string; "" → nil. +func parseOptionalUUID(s string) (*uuid.UUID, error) { + if s == "" { + return nil, nil + } + id, err := uuid.Parse(s) + if err != nil { + return nil, domain.NewValidationError("invalid id: " + s) + } + return &id, nil +} + +// ============================================================================= +// Audits +// ============================================================================= + +type createAuditBody struct { + Title string `json:"title"` + FrameworkID string `json:"framework_id"` + Type string `json:"type"` + Auditor string `json:"auditor"` + Scope string `json:"scope"` + ScheduledStart string `json:"scheduled_start"` + ScheduledEnd string `json:"scheduled_end"` +} + +func (h *ComplianceAuditHandler) ListAudits(c *fiber.Ctx) error { + audits, err := h.listAudits.Execute(c.UserContext(), tenantID(c)) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audits) +} + +func (h *ComplianceAuditHandler) CreateAudit(c *fiber.Ctx) error { + var body createAuditBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + fwID, err := parseOptionalUUID(body.FrameworkID) + if err != nil { + return writeAppError(c, err) + } + start, err := parseOptionalDate(body.ScheduledStart) + if err != nil { + return writeAppError(c, err) + } + end, err := parseOptionalDate(body.ScheduledEnd) + if err != nil { + return writeAppError(c, err) + } + audit, err := h.createAudit.Execute(c.UserContext(), tenantID(c), userID(c), complianceaudit.CreateAuditInput{ + Title: body.Title, + FrameworkID: fwID, + Type: body.Type, + Auditor: body.Auditor, + Scope: body.Scope, + ScheduledStart: start, + ScheduledEnd: end, + }) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(audit) +} + +func (h *ComplianceAuditHandler) GetAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + audit, err := h.getAudit.Execute(c.UserContext(), tenantID(c), id) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audit) +} + +type updateAuditBody struct { + Title *string `json:"title"` + FrameworkID *string `json:"framework_id"` // "" clears (program-wide) + Type *string `json:"type"` + Status *string `json:"status"` + Auditor *string `json:"auditor"` + Scope *string `json:"scope"` + Summary *string `json:"summary"` + ComplianceScore *float64 `json:"compliance_score"` + ScheduledStart *string `json:"scheduled_start"` + ScheduledEnd *string `json:"scheduled_end"` +} + +func (h *ComplianceAuditHandler) UpdateAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + var body updateAuditBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + + in := complianceaudit.UpdateAuditInput{ + Title: body.Title, + Type: body.Type, + Status: body.Status, + Auditor: body.Auditor, + Scope: body.Scope, + Summary: body.Summary, + ComplianceScore: body.ComplianceScore, + } + if body.FrameworkID != nil { + if *body.FrameworkID == "" { + in.ClearFramework = true + } else { + fwID, err := parseOptionalUUID(*body.FrameworkID) + if err != nil { + return writeAppError(c, err) + } + in.FrameworkID = fwID + } + } + if body.ScheduledStart != nil { + start, err := parseOptionalDate(*body.ScheduledStart) + if err != nil { + return writeAppError(c, err) + } + in.ScheduledStart = start + } + if body.ScheduledEnd != nil { + end, err := parseOptionalDate(*body.ScheduledEnd) + if err != nil { + return writeAppError(c, err) + } + in.ScheduledEnd = end + } + + audit, err := h.updateAudit.Execute(c.UserContext(), tenantID(c), id, in) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audit) +} + +func (h *ComplianceAuditHandler) DeleteAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + if err := h.deleteAudit.Execute(c.UserContext(), tenantID(c), id); err != nil { + return writeAppError(c, err) + } + return c.SendStatus(204) +} + +// GenerateRemediations POST /compliance/audits/:id/generate-remediations — +// opens a remediation plan for every open gap under the audit's framework, in one +// click. Idempotent: gaps that already have an active plan are skipped. +func (h *ComplianceAuditHandler) GenerateRemediations(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + res, err := h.generateRemediations.Execute(c.UserContext(), tenantID(c), id, userID(c)) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(res) +} + +// ============================================================================= +// Remediation plans +// ============================================================================= + +type createRemediationBody struct { + Title string `json:"title"` + Description string `json:"description"` + ControlID string `json:"control_id"` + AuditID string `json:"audit_id"` + Priority string `json:"priority"` + AssignedTo string `json:"assigned_to"` + DueDate string `json:"due_date"` +} + +func (h *ComplianceAuditHandler) ListRemediations(c *fiber.Ctx) error { + filter := domain.RemediationFilter{} + if v := c.Query("control_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.ControlID = id + } + if v := c.Query("framework_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.FrameworkID = id + } + if v := c.Query("audit_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.AuditID = id + } + if v := c.Query("status"); v != "" { + st, err := domain.ParseRemediationStatus(v) + if err != nil { + return writeAppError(c, err) + } + filter.Status = st + } + plans, err := h.listRemediations.Execute(c.UserContext(), tenantID(c), filter) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(plans) +} + +func (h *ComplianceAuditHandler) CreateRemediation(c *fiber.Ctx) error { + var body createRemediationBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + ctrlID, err := parseOptionalUUID(body.ControlID) + if err != nil { + return writeAppError(c, err) + } + auditID, err := parseOptionalUUID(body.AuditID) + if err != nil { + return writeAppError(c, err) + } + assignee, err := parseOptionalUUID(body.AssignedTo) + if err != nil { + return writeAppError(c, err) + } + due, err := parseOptionalDate(body.DueDate) + if err != nil { + return writeAppError(c, err) + } + plan, err := h.createRemediation.Execute(c.UserContext(), tenantID(c), userID(c), complianceaudit.CreateRemediationInput{ + Title: body.Title, + Description: body.Description, + ControlID: ctrlID, + AuditID: auditID, + Priority: body.Priority, + AssignedTo: assignee, + DueDate: due, + }) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(plan) +} + +type updateRemediationBody struct { + Title *string `json:"title"` + Description *string `json:"description"` + Priority *string `json:"priority"` + Status *string `json:"status"` + AssignedTo *string `json:"assigned_to"` // "" clears + DueDate *string `json:"due_date"` // "" clears +} + +func (h *ComplianceAuditHandler) UpdateRemediation(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid remediation id"}) + } + var body updateRemediationBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + in := complianceaudit.UpdateRemediationInput{ + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + Status: body.Status, + } + if body.AssignedTo != nil { + if *body.AssignedTo == "" { + in.ClearAssignee = true + } else { + a, err := parseOptionalUUID(*body.AssignedTo) + if err != nil { + return writeAppError(c, err) + } + in.AssignedTo = a + } + } + if body.DueDate != nil { + if *body.DueDate == "" { + in.ClearDueDate = true + } else { + d, err := parseOptionalDate(*body.DueDate) + if err != nil { + return writeAppError(c, err) + } + in.DueDate = d + } + } + plan, err := h.updateRemediation.Execute(c.UserContext(), tenantID(c), id, in) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(plan) +} + +func (h *ComplianceAuditHandler) DeleteRemediation(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid remediation id"}) + } + if err := h.deleteRemediation.Execute(c.UserContext(), tenantID(c), id); err != nil { + return writeAppError(c, err) + } + return c.SendStatus(204) +} diff --git a/backend/internal/handler/compliance_handler.go b/backend/internal/handler/compliance_handler.go index cb33a9a1..7ed79535 100644 --- a/backend/internal/handler/compliance_handler.go +++ b/backend/internal/handler/compliance_handler.go @@ -44,6 +44,11 @@ type ComplianceHandler struct { listCatalogsUC *compliance.ListCatalogsUseCase importCatalogUC *compliance.ImportCatalogUseCase generateReportUC *compliance.GenerateComplianceReportUseCase + getGapAnalysisUC *compliance.GetGapAnalysisUseCase + + createMappingUC *compliance.CreateControlMappingUseCase + listMappingsUC *compliance.ListControlMappingsUseCase + deleteMappingUC *compliance.DeleteControlMappingUseCase } func NewComplianceHandler( @@ -64,6 +69,10 @@ func NewComplianceHandler( listCatalogs *compliance.ListCatalogsUseCase, importCatalog *compliance.ImportCatalogUseCase, generateReport *compliance.GenerateComplianceReportUseCase, + getGapAnalysis *compliance.GetGapAnalysisUseCase, + createMapping *compliance.CreateControlMappingUseCase, + listMappings *compliance.ListControlMappingsUseCase, + deleteMapping *compliance.DeleteControlMappingUseCase, ) *ComplianceHandler { return &ComplianceHandler{ createFrameworkUC: createFramework, @@ -83,6 +92,10 @@ func NewComplianceHandler( listCatalogsUC: listCatalogs, importCatalogUC: importCatalog, generateReportUC: generateReport, + getGapAnalysisUC: getGapAnalysis, + createMappingUC: createMapping, + listMappingsUC: listMappings, + deleteMappingUC: deleteMapping, } } @@ -183,6 +196,94 @@ func (h *ComplianceHandler) GetProgress(c *fiber.Ctx) error { return c.JSON(progress) } +// GetGapAnalysis godoc — the "analyse d'écarts" endpoint. Returns every +// unsatisfied control (not implemented / in progress) across the tenant's +// frameworks, with per-framework and overall roll-ups. An optional +// ?framework_id= query param scopes the analysis to a single framework. +func (h *ComplianceHandler) GetGapAnalysis(c *fiber.Ctx) error { + frameworkID := uuid.Nil + if raw := c.Query("framework_id"); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid framework id"}) + } + frameworkID = id + } + analysis, err := h.getGapAnalysisUC.Execute(c.UserContext(), tenantID(c), frameworkID) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(analysis) +} + +// ============================================================================= +// Cross-framework control mappings ("cross-mapping entre référentiels") +// ============================================================================= + +// ListControlMappings GET /compliance/control-mappings[?control_id=] — the +// tenant's crosswalks, optionally scoped to one control. +func (h *ComplianceHandler) ListControlMappings(c *fiber.Ctx) error { + var controlID *uuid.UUID + if raw := c.Query("control_id"); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid control id"}) + } + controlID = &id + } + mappings, err := h.listMappingsUC.Execute(c.UserContext(), tenantID(c), controlID) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(mappings) +} + +type createMappingInput struct { + SourceControlID string `json:"source_control_id" validate:"required"` + TargetControlID string `json:"target_control_id" validate:"required"` + Relation string `json:"relation"` + Note string `json:"note"` +} + +// CreateControlMapping POST /compliance/control-mappings — link two controls +// (normally across frameworks). +func (h *ComplianceHandler) CreateControlMapping(c *fiber.Ctx) error { + input := new(createMappingInput) + if err := c.BodyParser(input); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + src, err := uuid.Parse(input.SourceControlID) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid source control id"}) + } + tgt, err := uuid.Parse(input.TargetControlID) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid target control id"}) + } + m, err := h.createMappingUC.Execute(c.UserContext(), tenantID(c), userID(c), compliance.CreateControlMappingInput{ + SourceControlID: src, + TargetControlID: tgt, + Relation: input.Relation, + Note: input.Note, + }) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(m) +} + +// DeleteControlMapping DELETE /compliance/control-mappings/:mappingId +func (h *ComplianceHandler) DeleteControlMapping(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("mappingId")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid mapping id"}) + } + if err := h.deleteMappingUC.Execute(c.UserContext(), tenantID(c), id); err != nil { + return writeAppError(c, err) + } + return c.SendStatus(204) +} + // GenerateReport godoc — streams an official compliance report (PDF) for one // framework in a single click. Data is strictly tenant-scoped; the framework is // global but only the requesting tenant's controls/evidence appear. The locale diff --git a/backend/internal/handler/compliance_handler_test.go b/backend/internal/handler/compliance_handler_test.go index 9029ca52..a1103866 100644 --- a/backend/internal/handler/compliance_handler_test.go +++ b/backend/internal/handler/compliance_handler_test.go @@ -67,6 +67,14 @@ func setupComplianceSchema(t *testing.T) *gorm.DB { uploaded_by TEXT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME ); `).Error) + require.NoError(t, db.Exec(` + CREATE TABLE control_mappings ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + source_control_id TEXT NOT NULL, target_control_id TEXT NOT NULL, + relation TEXT NOT NULL DEFAULT 'equivalent', note TEXT, created_by TEXT, + created_at DATETIME, updated_at DATETIME, deleted_at DATETIME + ); + `).Error) return db } @@ -83,6 +91,7 @@ func buildComplianceApp(t *testing.T, db *gorm.DB, store storage.Storage, tenant require.NoError(t, ps.InitializeDefaultRoles()) repo := repository.NewGormComplianceRepository(db) + mappingRepo := repository.NewGormControlMappingRepository(db) h := NewComplianceHandler( applicationcompliance.NewCreateFrameworkUseCase(repo), applicationcompliance.NewGetFrameworkUseCase(repo), @@ -101,6 +110,10 @@ func buildComplianceApp(t *testing.T, db *gorm.DB, store storage.Storage, tenant applicationcompliance.NewListCatalogsUseCase(), applicationcompliance.NewImportCatalogUseCase(repo), applicationcompliance.NewGenerateComplianceReportUseCase(repo, repository.NewGormOrganizationRepository(db), repository.NewGormUserRepository(db)), + applicationcompliance.NewGetGapAnalysisUseCase(repo), + applicationcompliance.NewCreateControlMappingUseCase(mappingRepo, repo), + applicationcompliance.NewListControlMappingsUseCase(mappingRepo, repo), + applicationcompliance.NewDeleteControlMappingUseCase(mappingRepo), ) app := fiber.New() @@ -128,6 +141,7 @@ func buildComplianceApp(t *testing.T, db *gorm.DB, store storage.Storage, tenant api.Post("/compliance/frameworks", frameworkCreate, h.CreateFramework) api.Get("/compliance/frameworks/:frameworkId", frameworkRead, h.GetFramework) api.Get("/compliance/frameworks/:frameworkId/progress", controlRead, h.GetProgress) + api.Get("/compliance/gap-analysis", controlRead, h.GetGapAnalysis) api.Get("/compliance/frameworks/:frameworkId/controls", controlRead, h.ListControls) api.Post("/compliance/frameworks/:frameworkId/controls", controlCreate, h.CreateControl) api.Get("/compliance/controls/:controlId", controlRead, h.GetControl) diff --git a/backend/internal/handler/risk_handler_test.go b/backend/internal/handler/risk_handler_test.go index c54b030d..25aef7dc 100644 --- a/backend/internal/handler/risk_handler_test.go +++ b/backend/internal/handler/risk_handler_test.go @@ -105,22 +105,47 @@ func setupAppWithDB(t *testing.T) *fiber.App { if err := db.AutoMigrate(&UserT{}, &MitigationT{}, &AssetT{}, &RiskHistoryT{}); err != nil { t.Fatalf("auto migrate failed: %v", err) } + // Hand-written (AutoMigrate can't build this model on sqlite: gen_random_uuid() + // default + pq array columns). Kept in sync with domain.Risk — every column the + // repository INSERT/RETURNING touches must exist, else the insert 400s. This + // had drifted (missing tenant_id + ~15 newer columns), which was the real cause + // of the long-standing TestRiskCRUDFlow failure. if err := db.Exec(`CREATE TABLE IF NOT EXISTS risks ( id TEXT PRIMARY KEY, + tenant_id TEXT, + organization_id TEXT, + name TEXT, title TEXT NOT NULL, description TEXT, - impact INTEGER, - probability INTEGER, + probability REAL, + impact REAL, score REAL, + criticality TEXT, + impact_legacy INTEGER, + probability_legacy INTEGER, status TEXT, - tags TEXT, + level TEXT, + lifecycle_phase TEXT, + created_by TEXT, + assigned_to TEXT, + reviewer_id TEXT, owner TEXT, + asset_id TEXT, + treatment_plan TEXT, + residual_risk REAL, + last_mitigated_at DATETIME, + slexaf REAL, + aro REAL, + review_interval_days INTEGER, + next_review_at DATETIME, + last_reviewed_at DATETIME, source TEXT, + source_cve_id TEXT, external_id TEXT, - level TEXT, custom_fields TEXT, + tags TEXT, frameworks TEXT, - organization_id TEXT, + control_ids TEXT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME @@ -176,7 +201,9 @@ func TestRiskCRUDFlow(t *testing.T) { "title": "Test Risk", "description": "desc", "impact": 3, - "probability": 4, + // Probability is on the Score Engine's 0.0–1.0 scale (validated min=0,max=1); + // the old 1–5 value here (4) always failed validation → the pre-existing 400. + "probability": 0.4, } b, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/risks", bytes.NewReader(b)) @@ -231,7 +258,7 @@ func TestCreateValidationFail(t *testing.T) { app := setupAppWithDB(t) // Missing required title - payload := map[string]interface{}{"impact": 3, "probability": 4} + payload := map[string]interface{}{"impact": 3, "probability": 0.4} b, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/risks", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go new file mode 100644 index 00000000..a19452a3 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/opendefender/openrisk/internal/domain" +) + +// GormComplianceAuditRepository implements domain.ComplianceAuditRepository and +// domain.RemediationPlanRepository using GORM. Both are tenant-scoped on every +// query — a resource owned by another tenant reads back as not found (nil, nil). +type GormComplianceAuditRepository struct { + db *gorm.DB +} + +func NewGormComplianceAuditRepository(db *gorm.DB) *GormComplianceAuditRepository { + return &GormComplianceAuditRepository{db: db} +} + +// ============================================================================= +// Audits +// ============================================================================= + +func (r *GormComplianceAuditRepository) CreateAudit(ctx context.Context, a *domain.ComplianceAudit) error { + if a.TenantID == uuid.Nil { + return fmt.Errorf("tenant_id is required") + } + return r.db.WithContext(ctx).Create(a).Error +} + +func (r *GormComplianceAuditRepository) GetAuditByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.ComplianceAudit, error) { + var a domain.ComplianceAudit + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&a).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get audit: %w", err) + } + return &a, nil +} + +func (r *GormComplianceAuditRepository) ListAudits(ctx context.Context, tenantID uuid.UUID) ([]domain.ComplianceAudit, error) { + var audits []domain.ComplianceAudit + err := r.db.WithContext(ctx). + Where("tenant_id = ?", tenantID). + Order("COALESCE(scheduled_start, created_at) DESC"). + Find(&audits).Error + if err != nil { + return nil, fmt.Errorf("failed to list audits: %w", err) + } + return audits, nil +} + +func (r *GormComplianceAuditRepository) UpdateAudit(ctx context.Context, a *domain.ComplianceAudit) error { + // Scope the write to the tenant so a forged ID can never touch another + // tenant's row. Save on a tenant-filtered query updates only the matched row. + res := r.db.WithContext(ctx). + Model(&domain.ComplianceAudit{}). + Where("id = ? AND tenant_id = ?", a.ID, a.TenantID). + Updates(map[string]interface{}{ + "title": a.Title, + "framework_id": a.FrameworkID, + "type": a.Type, + "status": a.Status, + "auditor": a.Auditor, + "scope": a.Scope, + "summary": a.Summary, + "compliance_score": a.ComplianceScore, + "scheduled_start": a.ScheduledStart, + "scheduled_end": a.ScheduledEnd, + "completed_at": a.CompletedAt, + }) + if res.Error != nil { + return fmt.Errorf("failed to update audit: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.NewNotFoundError("audit", a.ID) + } + return nil +} + +func (r *GormComplianceAuditRepository) DeleteAudit(ctx context.Context, id, tenantID uuid.UUID) error { + res := r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.ComplianceAudit{}) + if res.Error != nil { + return fmt.Errorf("failed to delete audit: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.NewNotFoundError("audit", id) + } + return nil +} + +// ============================================================================= +// Remediation plans +// ============================================================================= + +func (r *GormComplianceAuditRepository) CreateRemediation(ctx context.Context, rp *domain.RemediationPlan) error { + if rp.TenantID == uuid.Nil { + return fmt.Errorf("tenant_id is required") + } + return r.db.WithContext(ctx).Create(rp).Error +} + +func (r *GormComplianceAuditRepository) GetRemediationByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.RemediationPlan, error) { + var rp domain.RemediationPlan + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&rp).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get remediation plan: %w", err) + } + return &rp, nil +} + +func (r *GormComplianceAuditRepository) ListRemediations(ctx context.Context, tenantID uuid.UUID, filter domain.RemediationFilter) ([]domain.RemediationPlan, error) { + q := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID) + if filter.ControlID != nil { + q = q.Where("control_id = ?", *filter.ControlID) + } + if filter.FrameworkID != nil { + q = q.Where("framework_id = ?", *filter.FrameworkID) + } + if filter.AuditID != nil { + q = q.Where("audit_id = ?", *filter.AuditID) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + + var plans []domain.RemediationPlan + if err := q.Order("created_at DESC").Find(&plans).Error; err != nil { + return nil, fmt.Errorf("failed to list remediation plans: %w", err) + } + return plans, nil +} + +func (r *GormComplianceAuditRepository) UpdateRemediation(ctx context.Context, rp *domain.RemediationPlan) error { + res := r.db.WithContext(ctx). + Model(&domain.RemediationPlan{}). + Where("id = ? AND tenant_id = ?", rp.ID, rp.TenantID). + Updates(map[string]interface{}{ + "title": rp.Title, + "description": rp.Description, + "control_id": rp.ControlID, + "framework_id": rp.FrameworkID, + "audit_id": rp.AuditID, + "priority": rp.Priority, + "status": rp.Status, + "assigned_to": rp.AssignedTo, + "due_date": rp.DueDate, + "completed_at": rp.CompletedAt, + }) + if res.Error != nil { + return fmt.Errorf("failed to update remediation plan: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.NewNotFoundError("remediation plan", rp.ID) + } + return nil +} + +func (r *GormComplianceAuditRepository) DeleteRemediation(ctx context.Context, id, tenantID uuid.UUID) error { + res := r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.RemediationPlan{}) + if res.Error != nil { + return fmt.Errorf("failed to delete remediation plan: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.NewNotFoundError("remediation plan", id) + } + return nil +} diff --git a/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go new file mode 100644 index 00000000..a3a3b8ce --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupAuditRepo(t *testing.T) *GormComplianceAuditRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, db.Exec(` + CREATE TABLE compliance_audits ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + title TEXT NOT NULL, + framework_id TEXT, + type TEXT NOT NULL DEFAULT 'internal', + status TEXT NOT NULL DEFAULT 'planned', + auditor TEXT, + scope TEXT, + summary TEXT, + compliance_score REAL, + scheduled_start DATETIME, + scheduled_end DATETIME, + completed_at DATETIME, + created_by TEXT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + ); + `).Error) + + require.NoError(t, db.Exec(` + CREATE TABLE remediation_plans ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + control_id TEXT, + framework_id TEXT, + audit_id TEXT, + priority TEXT NOT NULL DEFAULT 'medium', + status TEXT NOT NULL DEFAULT 'open', + assigned_to TEXT, + due_date DATETIME, + completed_at DATETIME, + created_by TEXT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + ); + `).Error) + + return NewGormComplianceAuditRepository(db) +} + +func TestAuditRepo_CreateGet_TenantIsolation(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "ISO 27001 internal audit", Type: domain.AuditTypeInternal, Status: domain.AuditStatusPlanned} + require.NoError(t, repo.CreateAudit(ctx, a)) + + // Same tenant → found. + got, err := repo.GetAuditByID(ctx, a.ID, tenantA) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "ISO 27001 internal audit", got.Title) + + // Other tenant → nil (never 403, never leak). + other, err := repo.GetAuditByID(ctx, a.ID, tenantB) + require.NoError(t, err) + assert.Nil(t, other) +} + +func TestAuditRepo_CreateRequiresTenant(t *testing.T) { + repo := setupAuditRepo(t) + err := repo.CreateAudit(context.Background(), &domain.ComplianceAudit{ID: uuid.New(), Title: "x"}) + assert.Error(t, err) +} + +func TestAuditRepo_List_ScopedToTenant(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "A1"})) + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "A2"})) + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantB, Title: "B1"})) + + list, err := repo.ListAudits(ctx, tenantA) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestAuditRepo_Update_CrossTenantRefused(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "orig", Status: domain.AuditStatusPlanned} + require.NoError(t, repo.CreateAudit(ctx, a)) + + // Forge the same ID but another tenant → no row matched → ErrNotFound. + forged := &domain.ComplianceAudit{ID: a.ID, TenantID: tenantB, Title: "hijacked", Status: domain.AuditStatusCompleted} + err := repo.UpdateAudit(ctx, forged) + assert.ErrorIs(t, err, domain.ErrNotFound) + + // Original untouched. + got, _ := repo.GetAuditByID(ctx, a.ID, tenantA) + require.NotNil(t, got) + assert.Equal(t, "orig", got.Title) +} + +func TestAuditRepo_Delete(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenant := uuid.New() + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenant, Title: "gone"} + require.NoError(t, repo.CreateAudit(ctx, a)) + require.NoError(t, repo.DeleteAudit(ctx, a.ID, tenant)) + // Deleting again → not found. + assert.ErrorIs(t, repo.DeleteAudit(ctx, a.ID, tenant), domain.ErrNotFound) +} + +func TestRemediationRepo_CreateListFilter_TenantIsolation(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + ctrl := uuid.New() + + p1 := &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "Patch web-01", ControlID: &ctrl, Priority: domain.RemediationPriorityHigh, Status: domain.RemediationStatusOpen} + require.NoError(t, repo.CreateRemediation(ctx, p1)) + require.NoError(t, repo.CreateRemediation(ctx, &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "Other", Priority: domain.RemediationPriorityLow, Status: domain.RemediationStatusInProgress})) + require.NoError(t, repo.CreateRemediation(ctx, &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantB, Title: "B plan", Priority: domain.RemediationPriorityLow, Status: domain.RemediationStatusOpen})) + + // Tenant scoping. + all, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{}) + require.NoError(t, err) + assert.Len(t, all, 2) + + // Filter by control id. + byCtrl, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{ControlID: &ctrl}) + require.NoError(t, err) + require.Len(t, byCtrl, 1) + assert.Equal(t, "Patch web-01", byCtrl[0].Title) + + // Filter by status. + open, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{Status: domain.RemediationStatusOpen}) + require.NoError(t, err) + assert.Len(t, open, 1) + + // Cross-tenant get → nil. + got, err := repo.GetRemediationByID(ctx, p1.ID, tenantB) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestRemediationRepo_Update_CrossTenantRefused(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + p := &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "orig", Priority: domain.RemediationPriorityMedium, Status: domain.RemediationStatusOpen} + require.NoError(t, repo.CreateRemediation(ctx, p)) + + forged := &domain.RemediationPlan{ID: p.ID, TenantID: tenantB, Title: "hijacked", Priority: domain.RemediationPriorityCritical, Status: domain.RemediationStatusCompleted} + assert.ErrorIs(t, repo.UpdateRemediation(ctx, forged), domain.ErrNotFound) +} diff --git a/backend/internal/infrastructure/repository/gorm_control_mapping_repository.go b/backend/internal/infrastructure/repository/gorm_control_mapping_repository.go new file mode 100644 index 00000000..e6f19cf7 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_control_mapping_repository.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/opendefender/openrisk/internal/domain" +) + +// GormControlMappingRepository implements domain.ControlMappingRepository. +// Tenant-scoped on every query. +type GormControlMappingRepository struct { + db *gorm.DB +} + +func NewGormControlMappingRepository(db *gorm.DB) *GormControlMappingRepository { + return &GormControlMappingRepository{db: db} +} + +func (r *GormControlMappingRepository) Create(ctx context.Context, m *domain.ControlMapping) error { + if m.TenantID == uuid.Nil { + return fmt.Errorf("tenant_id is required") + } + return r.db.WithContext(ctx).Create(m).Error +} + +func (r *GormControlMappingRepository) Exists(ctx context.Context, tenantID, a, b uuid.UUID) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&domain.ControlMapping{}). + Where("tenant_id = ?", tenantID). + Where("(source_control_id = ? AND target_control_id = ?) OR (source_control_id = ? AND target_control_id = ?)", a, b, b, a). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("failed to check mapping existence: %w", err) + } + return count > 0, nil +} + +func (r *GormControlMappingRepository) List(ctx context.Context, tenantID uuid.UUID, controlID *uuid.UUID) ([]domain.ControlMapping, error) { + q := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID) + if controlID != nil { + q = q.Where("source_control_id = ? OR target_control_id = ?", *controlID, *controlID) + } + var mappings []domain.ControlMapping + if err := q.Order("created_at DESC").Find(&mappings).Error; err != nil { + return nil, fmt.Errorf("failed to list mappings: %w", err) + } + return mappings, nil +} + +func (r *GormControlMappingRepository) Delete(ctx context.Context, id, tenantID uuid.UUID) error { + res := r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.ControlMapping{}) + if res.Error != nil { + return fmt.Errorf("failed to delete mapping: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.NewNotFoundError("control mapping", id) + } + return nil +} diff --git a/backend/internal/infrastructure/repository/gorm_control_mapping_repository_test.go b/backend/internal/infrastructure/repository/gorm_control_mapping_repository_test.go new file mode 100644 index 00000000..96384930 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_control_mapping_repository_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupMappingRepo(t *testing.T) *GormControlMappingRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true}) + require.NoError(t, err) + // Raw CREATE TABLE rather than AutoMigrate: the model's id default is + // gen_random_uuid() (Postgres), which sqlite's parser rejects — same reason + // the other repo tests hand-write their schema. + require.NoError(t, db.Exec(` + CREATE TABLE control_mappings ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + source_control_id TEXT NOT NULL, + target_control_id TEXT NOT NULL, + relation TEXT NOT NULL DEFAULT 'equivalent', + note TEXT, + created_by TEXT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + ); + `).Error) + return NewGormControlMappingRepository(db) +} + +func TestControlMappingRepo_CreateAndExistsBothDirections(t *testing.T) { + repo := setupMappingRepo(t) + ctx := context.Background() + tenant := uuid.New() + a, b := uuid.New(), uuid.New() + + require.NoError(t, repo.Create(ctx, &domain.ControlMapping{ + ID: uuid.New(), TenantID: tenant, SourceControlID: a, TargetControlID: b, Relation: domain.MappingRelationEquivalent, + })) + + // Exists must be symmetric: A→B and B→A are the same mapping. + fwd, err := repo.Exists(ctx, tenant, a, b) + require.NoError(t, err) + assert.True(t, fwd) + rev, err := repo.Exists(ctx, tenant, b, a) + require.NoError(t, err) + assert.True(t, rev, "existence check must be direction-independent") + + // A different pair does not exist. + none, err := repo.Exists(ctx, tenant, a, uuid.New()) + require.NoError(t, err) + assert.False(t, none) +} + +func TestControlMappingRepo_ListAllAndByControl(t *testing.T) { + repo := setupMappingRepo(t) + ctx := context.Background() + tenant := uuid.New() + a, b, c := uuid.New(), uuid.New(), uuid.New() + + require.NoError(t, repo.Create(ctx, &domain.ControlMapping{ID: uuid.New(), TenantID: tenant, SourceControlID: a, TargetControlID: b})) + require.NoError(t, repo.Create(ctx, &domain.ControlMapping{ID: uuid.New(), TenantID: tenant, SourceControlID: b, TargetControlID: c})) + + all, err := repo.List(ctx, tenant, nil) + require.NoError(t, err) + assert.Len(t, all, 2) + + // Scoped to control a → only the a↔b mapping. + scoped, err := repo.List(ctx, tenant, &a) + require.NoError(t, err) + assert.Len(t, scoped, 1) + + // Scoped to b → both (b is source of one, target of the other). + scopedB, err := repo.List(ctx, tenant, &b) + require.NoError(t, err) + assert.Len(t, scopedB, 2) +} + +func TestControlMappingRepo_TenantIsolationAndDelete(t *testing.T) { + repo := setupMappingRepo(t) + ctx := context.Background() + t1, t2 := uuid.New(), uuid.New() + a, b := uuid.New(), uuid.New() + + m := &domain.ControlMapping{ID: uuid.New(), TenantID: t1, SourceControlID: a, TargetControlID: b} + require.NoError(t, repo.Create(ctx, m)) + + // Another tenant sees nothing. + other, err := repo.List(ctx, t2, nil) + require.NoError(t, err) + assert.Empty(t, other) + + // Another tenant cannot delete it (reads back as not found). + err = repo.Delete(ctx, m.ID, t2) + assert.ErrorIs(t, err, domain.ErrNotFound) + + // Owner deletes it. + require.NoError(t, repo.Delete(ctx, m.ID, t1)) + mine, err := repo.List(ctx, t1, nil) + require.NoError(t, err) + assert.Empty(t, mine) +} diff --git a/backend/pkg/compliance/catalog_dora_2022_2554.go b/backend/pkg/compliance/catalog_dora_2022_2554.go new file mode 100644 index 00000000..996ac0bc --- /dev/null +++ b/backend/pkg/compliance/catalog_dora_2022_2554.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// DORA — Règlement (UE) 2022/2554 sur la résilience opérationnelle numérique du +// secteur financier. Structuré autour de cinq piliers : gestion du risque TIC, +// gestion/notification des incidents, tests de résilience, risque lié aux tiers +// prestataires TIC, et partage d'informations. Modélisé au niveau des articles +// clés. Les numéros d'articles sont la structure publique du règlement (fiables) ; +// les descriptions sont des résumés originaux, pas le texte officiel. Vérifier +// contre le Règlement (UE) 2022/2554 avant un audit. + +func init() { + register(Catalog{ + Key: "dora-2022-2554", + Name: "DORA (UE 2022/2554)", + Version: "2022", + Description: "Digital Operational Resilience Act — résilience opérationnelle numérique du secteur financier : risque TIC, incidents, tests, tiers prestataires et partage d'informations.", + Available: true, + Controls: dora20222554Controls, + }) +} + +const doraSource = "DORA (UE) 2022/2554, art. " + +var dora20222554Controls = []CatalogControl{ + // Pilier 1 — Gestion du risque lié aux TIC (Chapitre II) + {"Art.5", "Gouvernance et organisation", "L'organe de direction définit, approuve et supervise le cadre de gestion du risque lié aux TIC et en porte la responsabilité finale.", doraSource + "5"}, + {"Art.6", "Cadre de gestion du risque lié aux TIC", "Disposer d'un cadre solide, documenté et réexaminé, couvrant les stratégies, politiques, procédures et outils de protection des actifs informationnels et TIC.", doraSource + "6"}, + {"Art.7", "Systèmes, protocoles et outils TIC", "Utiliser et maintenir des systèmes, protocoles et outils TIC fiables, dotés de capacités suffisantes et technologiquement résilients.", doraSource + "7"}, + {"Art.8", "Identification", "Identifier, classer et documenter les fonctions métier, actifs informationnels et actifs TIC ainsi que leurs interdépendances.", doraSource + "8"}, + {"Art.9", "Protection et prévention", "Mettre en œuvre des politiques et mesures de sécurité (contrôle d'accès, chiffrement, gestion des changements) pour protéger les systèmes TIC.", doraSource + "9"}, + {"Art.10", "Détection", "Mettre en place des mécanismes de détection rapide des activités anormales et des incidents potentiels liés aux TIC.", doraSource + "10"}, + {"Art.11", "Réponse et rétablissement", "Disposer de politiques de continuité des activités et de plans de réponse et de rétablissement TIC testés et à jour.", doraSource + "11"}, + {"Art.12", "Sauvegarde, restauration et rétablissement", "Définir des politiques et procédures de sauvegarde et des méthodes de restauration/rétablissement préservant l'intégrité des données.", doraSource + "12"}, + {"Art.13", "Apprentissage et évolution", "Recueillir les enseignements des incidents et tests pour faire évoluer le cadre de gestion du risque TIC et la sensibilisation.", doraSource + "13"}, + + // Pilier 2 — Gestion, classification et notification des incidents (Chapitre III) + {"Art.17", "Processus de gestion des incidents TIC", "Définir et mettre en œuvre un processus de détection, gestion et notification des incidents liés aux TIC.", doraSource + "17"}, + {"Art.18", "Classification des incidents et cybermenaces", "Classer les incidents liés aux TIC et évaluer leur importance selon les critères prévus (clients affectés, durée, portée géographique, pertes de données…).", doraSource + "18"}, + {"Art.19", "Notification des incidents majeurs", "Notifier les incidents majeurs liés aux TIC à l'autorité compétente selon les délais et modèles prescrits.", doraSource + "19"}, + + // Pilier 3 — Tests de résilience opérationnelle numérique (Chapitre IV) + {"Art.24", "Programme de tests de résilience", "Établir un programme de tests de résilience opérationnelle numérique proportionné, sain et complet.", doraSource + "24"}, + {"Art.25", "Tests des outils et systèmes TIC", "Tester régulièrement les outils et systèmes TIC (analyses de vulnérabilités, tests de sécurité, tests de continuité) et remédier aux faiblesses.", doraSource + "25"}, + {"Art.26", "Tests avancés (TLPT)", "Réaliser des tests de pénétration fondés sur la menace (Threat-Led Penetration Testing) pour les entités désignées.", doraSource + "26"}, + + // Pilier 4 — Gestion du risque lié aux tiers prestataires TIC (Chapitre V) + {"Art.28", "Principes de gestion du risque tiers TIC", "Gérer le risque lié aux prestataires tiers de services TIC dans le cadre global de gestion du risque, avec une stratégie et un registre d'information.", doraSource + "28"}, + {"Art.29", "Concentration du risque", "Évaluer le risque de concentration lié au recours à des prestataires tiers de services TIC, y compris la sous-traitance en chaîne.", doraSource + "29"}, + {"Art.30", "Dispositions contractuelles clés", "Encadrer les prestations TIC par des contrats comportant les clauses obligatoires (accès, audit, résiliation, niveaux de service, sécurité).", doraSource + "30"}, + + // Pilier 5 — Partage d'informations (Chapitre VI) + {"Art.45", "Partage d'informations sur les cybermenaces", "Participer, le cas échéant, à des accords de partage d'informations et de renseignements sur les cybermenaces au sein de communautés de confiance.", doraSource + "45"}, +} diff --git a/backend/pkg/compliance/catalog_gdpr_2016_679.go b/backend/pkg/compliance/catalog_gdpr_2016_679.go new file mode 100644 index 00000000..f25fd089 --- /dev/null +++ b/backend/pkg/compliance/catalog_gdpr_2016_679.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// RGPD — Règlement (UE) 2016/679 relatif à la protection des personnes physiques +// à l'égard du traitement des données à caractère personnel. Modélisé au niveau des +// articles opérationnels que le responsable de traitement/sous-traitant doit +// satisfaire. Les numéros et intitulés d'articles sont la structure publique du +// règlement (fiables) ; les descriptions sont des résumés originaux, pas le texte +// officiel. Vérifier contre le texte du Règlement (UE) 2016/679 avant un audit. +// Descriptions en français : cadre européen, marché cible francophone. + +func init() { + register(Catalog{ + Key: "gdpr-2016-679", + Name: "RGPD (UE 2016/679)", + Version: "2016", + Description: "Règlement Général sur la Protection des Données — obligations du responsable de traitement et du sous-traitant : principes, bases légales, droits des personnes, sécurité, violations, DPIA, DPO et transferts.", + Available: true, + Controls: gdpr2016679Controls, + }) +} + +const gdprSource = "RGPD (UE) 2016/679, art. " + +var gdpr2016679Controls = []CatalogControl{ + // Chapitre II — Principes + {"Art.5", "Principes relatifs au traitement", "Traiter les données de manière licite, loyale et transparente, pour des finalités déterminées, avec minimisation, exactitude, limitation de la conservation, intégrité/confidentialité et responsabilité (accountability).", gdprSource + "5"}, + {"Art.6", "Licéité du traitement", "Ne traiter des données que si au moins une base légale s'applique (consentement, contrat, obligation légale, intérêt vital, mission d'intérêt public, intérêt légitime).", gdprSource + "6"}, + {"Art.7", "Conditions applicables au consentement", "Pouvoir démontrer que la personne a consenti, présenter la demande de manière claire et distincte, et permettre un retrait aussi simple que le consentement.", gdprSource + "7"}, + {"Art.9", "Catégories particulières de données", "Interdire par principe le traitement des données sensibles (santé, biométrie, opinions…) sauf exception encadrée, et mettre en place des garanties renforcées.", gdprSource + "9"}, + + // Chapitre III — Droits de la personne concernée + {"Art.12-14", "Transparence et information", "Fournir une information concise, transparente et accessible sur le traitement, que les données soient collectées directement ou indirectement.", gdprSource + "12-14"}, + {"Art.15", "Droit d'accès", "Permettre à la personne d'obtenir la confirmation que ses données sont traitées, l'accès à ces données et les informations sur le traitement.", gdprSource + "15"}, + {"Art.16", "Droit de rectification", "Permettre la rectification et le complètement des données inexactes ou incomplètes dans les meilleurs délais.", gdprSource + "16"}, + {"Art.17", "Droit à l'effacement (« droit à l'oubli »)", "Effacer les données sur demande lorsque les conditions sont réunies et répercuter la demande aux destinataires et sous-traitants.", gdprSource + "17"}, + {"Art.18", "Droit à la limitation du traitement", "Restreindre le traitement dans les cas prévus (contestation d'exactitude, opposition, traitement illicite) plutôt que d'effacer.", gdprSource + "18"}, + {"Art.20", "Droit à la portabilité des données", "Restituer les données fournies par la personne dans un format structuré, couramment utilisé et lisible par machine, et les transmettre à un autre responsable si techniquement possible.", gdprSource + "20"}, + {"Art.21", "Droit d'opposition", "Permettre à la personne de s'opposer au traitement, notamment à la prospection commerciale et au profilage.", gdprSource + "21"}, + {"Art.22", "Décision individuelle automatisée", "Encadrer les décisions fondées exclusivement sur un traitement automatisé, y compris le profilage, produisant des effets juridiques.", gdprSource + "22"}, + + // Chapitre IV — Responsable du traitement et sous-traitant + {"Art.24", "Responsabilité du responsable de traitement", "Mettre en œuvre des mesures techniques et organisationnelles appropriées pour garantir et démontrer la conformité du traitement.", gdprSource + "24"}, + {"Art.25", "Protection des données dès la conception et par défaut", "Intégrer la protection des données dès la conception (privacy by design) et par défaut (privacy by default) dans les traitements.", gdprSource + "25"}, + {"Art.28", "Sous-traitant", "N'avoir recours qu'à des sous-traitants présentant des garanties suffisantes et encadrer la relation par un contrat conforme (article 28.3).", gdprSource + "28"}, + {"Art.30", "Registre des activités de traitement", "Tenir un registre des activités de traitement documentant finalités, catégories, destinataires, transferts et mesures de sécurité.", gdprSource + "30"}, + {"Art.32", "Sécurité du traitement", "Mettre en œuvre des mesures de sécurité adaptées au risque : pseudonymisation, chiffrement, confidentialité, intégrité, disponibilité, résilience et tests réguliers.", gdprSource + "32"}, + {"Art.33", "Notification d'une violation à l'autorité", "Notifier une violation de données à l'autorité de contrôle dans les 72 heures lorsqu'elle présente un risque pour les personnes.", gdprSource + "33"}, + {"Art.34", "Communication d'une violation à la personne", "Communiquer la violation aux personnes concernées dans les meilleurs délais lorsqu'elle engendre un risque élevé.", gdprSource + "34"}, + {"Art.35", "Analyse d'impact (AIPD/DPIA)", "Réaliser une analyse d'impact relative à la protection des données pour les traitements susceptibles d'engendrer un risque élevé.", gdprSource + "35"}, + {"Art.37-39", "Délégué à la protection des données (DPO)", "Désigner un DPO lorsque requis, garantir son indépendance et ses moyens, et lui confier l'information, le conseil et le contrôle de conformité.", gdprSource + "37-39"}, + {"Art.44-49", "Transferts vers des pays tiers", "N'effectuer de transferts hors UE que sous garanties appropriées (décision d'adéquation, clauses types, BCR) ou dérogation prévue.", gdprSource + "44-49"}, +} diff --git a/backend/pkg/compliance/catalog_iso27005_2022.go b/backend/pkg/compliance/catalog_iso27005_2022.go new file mode 100644 index 00000000..b13b894f --- /dev/null +++ b/backend/pkg/compliance/catalog_iso27005_2022.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// ISO/IEC 27005:2022 — Guidance on managing information security risks. Unlike +// ISO 27001, this is a process standard, not a control set: it describes the +// activities of the information security risk management process. We model it at +// the clause/activity level so a tenant can assess whether each activity of the +// process is in place. Clause numbers are ISO's own published structure and are +// reliable; the descriptions are original summaries of each activity's intent, +// not ISO's own text. Verify against ISO/IEC 27005:2022 before an audit. + +func init() { + register(Catalog{ + Key: "iso27005-2022", + Name: "ISO/IEC 27005", + Version: "2022", + Description: "Information security risk management — the activities of the ISO 27005 risk management process (context, assessment, treatment, operation, ISMS integration).", + Available: true, + Controls: iso270052022Controls, + }) +} + +const iso27005Source = "ISO/IEC 27005:2022, Clause " + +var iso270052022Controls = []CatalogControl{ + // Clause 5 — Information security risk management process + {"5", "Information Security Risk Management Process", "Establish and run an iterative information security risk management process aligned with the organization's ISMS, covering context establishment, assessment, treatment, acceptance, communication and monitoring.", iso27005Source + "5"}, + + // Clause 6 — Context establishment + {"6.1", "Organizational Considerations", "Establish the internal and external context, purpose and organizational considerations that frame information security risk management.", iso27005Source + "6.1"}, + {"6.2", "Identifying Basic Requirements of Interested Parties", "Identify the information security requirements and expectations of interested parties that risk management must satisfy.", iso27005Source + "6.2"}, + {"6.3", "Applying Risk Assessment", "Define and document the risk criteria — risk acceptance criteria and criteria for performing risk assessments — before assessment begins.", iso27005Source + "6.3"}, + + // Clause 7 — Information security risk assessment process + {"7.1", "General Risk Assessment Approach", "Define the overall approach to identifying, analysing and evaluating information security risks consistently across the organization.", iso27005Source + "7.1"}, + {"7.2", "Identifying Risks", "Identify the risks associated with the loss of confidentiality, integrity and availability of information, using an event-based and/or asset-based approach.", iso27005Source + "7.2"}, + {"7.3", "Analysing Risks", "Assess the potential consequences and the likelihood of identified risks and determine the resulting level of risk.", iso27005Source + "7.3"}, + {"7.4", "Evaluating Risks", "Compare the results of risk analysis against the risk criteria to prioritize risks and decide which require treatment.", iso27005Source + "7.4"}, + + // Clause 8 — Information security risk treatment process + {"8.1", "General Risk Treatment Approach", "Define how risk treatment options are selected and how the treatment process is documented and approved.", iso27005Source + "8.1"}, + {"8.2", "Selecting Risk Treatment Options", "Select appropriate treatment options (modify, retain, avoid or share the risk) based on the risk assessment results.", iso27005Source + "8.2"}, + {"8.3", "Determining Controls", "Determine the controls necessary to implement the chosen treatment options and compare them with a reference control set such as ISO 27001 Annex A.", iso27005Source + "8.3"}, + {"8.4", "Producing a Statement of Applicability", "Produce a Statement of Applicability justifying the inclusion or exclusion of controls and their implementation status.", iso27005Source + "8.4"}, + {"8.5", "Risk Treatment Plan", "Formulate a risk treatment plan assigning owners, resources, priorities and timelines to the selected controls.", iso27005Source + "8.5"}, + {"8.6", "Residual Risk Acceptance", "Obtain the risk owners' documented approval of the risk treatment plan and their acceptance of the residual risks.", iso27005Source + "8.6"}, + + // Clause 9 — Operation + {"9.1", "Performing Information Security Risk Assessment", "Perform information security risk assessments at planned intervals and when significant changes occur, retaining documented results.", iso27005Source + "9.1"}, + {"9.2", "Performing Information Security Risk Treatment", "Implement the risk treatment plan and retain documented information on the results of risk treatment.", iso27005Source + "9.2"}, + + // Clause 10 — Leveraging related ISMS processes + {"10.1", "Context of the Organization", "Integrate risk management with the ISMS's understanding of the organization and its context.", iso27005Source + "10.1"}, + {"10.2", "Monitoring and Review", "Continually monitor and review risks and the risk factors (value of assets, threats, vulnerabilities, likelihood, consequences) to keep the risk picture current.", iso27005Source + "10.2"}, + {"10.3", "Communication and Consultation", "Communicate and consult with internal and external interested parties about information security risks throughout the process.", iso27005Source + "10.3"}, +} diff --git a/backend/pkg/compliance/catalog_iso31000_2018.go b/backend/pkg/compliance/catalog_iso31000_2018.go new file mode 100644 index 00000000..4e018071 --- /dev/null +++ b/backend/pkg/compliance/catalog_iso31000_2018.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// ISO 31000:2018 — Risk management — Guidelines. A generic (all-risk, not just +// information security) framework built on three parts: the Principles (Clause 4), +// the Framework (Clause 5) and the Process (Clause 6). We model each principle, +// framework component and process activity as an assessable item. Clause numbers +// and component names are ISO's own published structure and are reliable; the +// descriptions are original summaries, not ISO's own text. Verify against +// ISO 31000:2018 before an audit. + +func init() { + register(Catalog{ + Key: "iso31000-2018", + Name: "ISO 31000", + Version: "2018", + Description: "Risk management — Guidelines. The 8 principles, the risk management framework (leadership, integration, design, implementation, evaluation, improvement) and the risk management process.", + Available: true, + Controls: iso310002018Controls, + }) +} + +const iso31000Source = "ISO 31000:2018, Clause " + +var iso310002018Controls = []CatalogControl{ + // Clause 4 — Principles + {"4-INT", "Principle: Integrated", "Risk management is an integral part of all organizational activities.", iso31000Source + "4 (Integrated)"}, + {"4-STR", "Principle: Structured and Comprehensive", "A structured and comprehensive approach to risk management contributes to consistent and comparable results.", iso31000Source + "4 (Structured and comprehensive)"}, + {"4-CUS", "Principle: Customized", "The risk management framework and process are customized and proportionate to the organization's external and internal context and objectives.", iso31000Source + "4 (Customized)"}, + {"4-INC", "Principle: Inclusive", "Appropriate and timely involvement of stakeholders enables their knowledge, views and perceptions to be considered.", iso31000Source + "4 (Inclusive)"}, + {"4-DYN", "Principle: Dynamic", "Risks can emerge, change or disappear as context changes; risk management anticipates, detects, acknowledges and responds to those changes in an appropriate and timely manner.", iso31000Source + "4 (Dynamic)"}, + {"4-INF", "Principle: Best Available Information", "The inputs to risk management are based on historical and current information, as well as on future expectations, with account taken of any limitations and uncertainties.", iso31000Source + "4 (Best available information)"}, + {"4-HUM", "Principle: Human and Cultural Factors", "Human behaviour and culture significantly influence all aspects of risk management at each level and stage.", iso31000Source + "4 (Human and cultural factors)"}, + {"4-IMP", "Principle: Continual Improvement", "Risk management is continually improved through learning and experience.", iso31000Source + "4 (Continual improvement)"}, + + // Clause 5 — Framework + {"5.2", "Leadership and Commitment", "Top management and oversight bodies ensure that risk management is integrated into all organizational activities and demonstrate leadership and commitment.", iso31000Source + "5.2"}, + {"5.3", "Integration", "Integrate risk management into the organization's structure, governance and all its activities, recognizing that it is dynamic and iterative.", iso31000Source + "5.3"}, + {"5.4", "Design", "Design the risk management framework by understanding the organization and its context, articulating commitment, assigning roles and authorities, and allocating resources.", iso31000Source + "5.4"}, + {"5.5", "Implementation", "Implement the framework by developing a plan, identifying decision-making, and ensuring arrangements for managing risk are understood and practised.", iso31000Source + "5.5"}, + {"5.6", "Evaluation", "Periodically measure the framework's performance against its purpose, implementation plans, indicators and expected behaviour.", iso31000Source + "5.6"}, + {"5.7", "Improvement", "Continually monitor, adapt and improve the risk management framework to address internal and external changes.", iso31000Source + "5.7"}, + + // Clause 6 — Process + {"6.2", "Communication and Consultation", "Assist relevant stakeholders in understanding risk, the basis for decisions, and the reasons particular actions are required, throughout the process.", iso31000Source + "6.2"}, + {"6.3", "Scope, Context and Criteria", "Define the scope of risk management activities and establish the external and internal context and the risk criteria.", iso31000Source + "6.3"}, + {"6.4.2", "Risk Identification", "Find, recognize and describe risks that might help or prevent the organization from achieving its objectives.", iso31000Source + "6.4.2"}, + {"6.4.3", "Risk Analysis", "Comprehend the nature of risk and its characteristics, including the level of risk, considering uncertainties, sources, consequences, likelihood, events, scenarios and controls.", iso31000Source + "6.4.3"}, + {"6.4.4", "Risk Evaluation", "Support decisions by comparing the results of risk analysis with the established risk criteria to determine where additional action is required.", iso31000Source + "6.4.4"}, + {"6.5", "Risk Treatment", "Select and implement options for addressing risk, formulate and implement risk treatment plans, and assess residual risk.", iso31000Source + "6.5"}, + {"6.6", "Monitoring and Review", "Assure and improve the quality and effectiveness of process design, implementation and outcomes through ongoing monitoring and periodic review.", iso31000Source + "6.6"}, + {"6.7", "Recording and Reporting", "Document and report the risk management process and its outcomes through appropriate mechanisms to support decisions and improve activities.", iso31000Source + "6.7"}, +} diff --git a/backend/pkg/compliance/catalog_nis2_2022_2555.go b/backend/pkg/compliance/catalog_nis2_2022_2555.go new file mode 100644 index 00000000..a80947ea --- /dev/null +++ b/backend/pkg/compliance/catalog_nis2_2022_2555.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// NIS2 — Directive (UE) 2022/2555 concernant des mesures pour un niveau élevé +// commun de cybersécurité dans l'ensemble de l'Union. Le cœur opérationnel est +// l'article 21 (mesures de gestion des risques de cybersécurité) qui énumère dix +// mesures minimales (a→j), encadré par la gouvernance (art. 20) et les obligations +// de notification (art. 23). Les numéros d'articles sont la structure publique de +// la directive (fiables) ; les descriptions sont des résumés originaux, pas le +// texte officiel. Vérifier contre la Directive (UE) 2022/2555 avant un audit. + +func init() { + register(Catalog{ + Key: "nis2-2022-2555", + Name: "NIS2 (UE 2022/2555)", + Version: "2022", + Description: "Directive NIS2 — cybersécurité des entités essentielles et importantes : gouvernance, les dix mesures de gestion des risques (art. 21) et les obligations de notification d'incidents.", + Available: true, + Controls: nis220222555Controls, + }) +} + +const nis2Source = "NIS2 (UE) 2022/2555, art. " + +var nis220222555Controls = []CatalogControl{ + // Gouvernance + {"Art.20", "Gouvernance", "Les organes de direction approuvent les mesures de gestion des risques de cybersécurité, en supervisent la mise en œuvre et suivent des formations dédiées.", nis2Source + "20"}, + + // Article 21 — les dix mesures minimales de gestion des risques (a → j) + {"Art.21(a)", "Politiques d'analyse des risques et de sécurité des SI", "Adopter des politiques relatives à l'analyse des risques et à la sécurité des systèmes d'information.", nis2Source + "21.2(a)"}, + {"Art.21(b)", "Gestion des incidents", "Mettre en place une capacité de gestion des incidents (prévention, détection et réponse).", nis2Source + "21.2(b)"}, + {"Art.21(c)", "Continuité des activités", "Assurer la continuité des activités : gestion des sauvegardes, reprise après sinistre et gestion de crise.", nis2Source + "21.2(c)"}, + {"Art.21(d)", "Sécurité de la chaîne d'approvisionnement", "Sécuriser la chaîne d'approvisionnement, y compris les relations avec les fournisseurs et prestataires de services directs.", nis2Source + "21.2(d)"}, + {"Art.21(e)", "Sécurité de l'acquisition, du développement et de la maintenance", "Intégrer la sécurité dans l'acquisition, le développement et la maintenance des réseaux et systèmes, y compris la gestion et la divulgation des vulnérabilités.", nis2Source + "21.2(e)"}, + {"Art.21(f)", "Évaluation de l'efficacité des mesures", "Définir des politiques et procédures pour évaluer l'efficacité des mesures de gestion des risques de cybersécurité.", nis2Source + "21.2(f)"}, + {"Art.21(g)", "Cyberhygiène et formation", "Mettre en œuvre des pratiques d'hygiène informatique de base et une formation à la cybersécurité.", nis2Source + "21.2(g)"}, + {"Art.21(h)", "Cryptographie et chiffrement", "Définir des politiques et procédures relatives à l'usage de la cryptographie et, le cas échéant, du chiffrement.", nis2Source + "21.2(h)"}, + {"Art.21(i)", "Sécurité des ressources humaines et contrôle d'accès", "Gérer la sécurité des ressources humaines, les politiques de contrôle d'accès et la gestion des actifs.", nis2Source + "21.2(i)"}, + {"Art.21(j)", "Authentification multifacteur et communications sécurisées", "Recourir à l'authentification multifacteur ou continue, et à des communications vocales, vidéo et textuelles sécurisées, ainsi qu'à des communications d'urgence sécurisées.", nis2Source + "21.2(j)"}, + + // Obligations de notification + {"Art.23", "Obligations de notification d'incidents", "Notifier sans retard injustifié tout incident important : alerte précoce (24 h), notification (72 h) et rapport final (1 mois).", nis2Source + "23"}, +} diff --git a/backend/pkg/compliance/catalog_nist_800_53_r5.go b/backend/pkg/compliance/catalog_nist_800_53_r5.go new file mode 100644 index 00000000..c4c38874 --- /dev/null +++ b/backend/pkg/compliance/catalog_nist_800_53_r5.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// NIST SP 800-53 Rev. 5 — Security and Privacy Controls for Information Systems +// and Organizations. The catalog has 1000+ base controls and enhancements across +// 20 control families. We model it at the family level (the 20 families), which is +// NIST's own public structure and is reliable; a tenant can add specific base +// controls (e.g. AC-2) as ad-hoc controls on the imported framework. Descriptions +// are original summaries of each family's purpose, not NIST's own text. Verify +// against NIST SP 800-53 Rev. 5 before an audit. + +func init() { + register(Catalog{ + Key: "nist-800-53-r5", + Name: "NIST SP 800-53", + Version: "Rev. 5", + Description: "Security and Privacy Controls for Information Systems and Organizations — the 20 control families (AC, AU, CM, IA, IR, RA, SC, SI, SR, PT, …).", + Available: true, + Controls: nist80053r5Controls, + }) +} + +const nist80053Source = "NIST SP 800-53 Rev. 5, Family " + +var nist80053r5Controls = []CatalogControl{ + {"AC", "Access Control", "Limit information system access to authorized users, processes and devices, and to the types of transactions and functions authorized users are permitted to exercise.", nist80053Source + "AC"}, + {"AT", "Awareness and Training", "Ensure that personnel are trained to carry out their assigned information security and privacy responsibilities and are aware of applicable policies, standards and procedures.", nist80053Source + "AT"}, + {"AU", "Audit and Accountability", "Create, protect and retain system audit records to enable monitoring, analysis, investigation and reporting of unlawful or unauthorized activity, and ensure actions can be traced to individuals.", nist80053Source + "AU"}, + {"CA", "Assessment, Authorization, and Monitoring", "Assess controls, authorize system operation, and continuously monitor security and privacy posture, including plans of action and milestones for deficiencies.", nist80053Source + "CA"}, + {"CM", "Configuration Management", "Establish and maintain baseline configurations and inventories of systems, and enforce configuration change control throughout the system development life cycle.", nist80053Source + "CM"}, + {"CP", "Contingency Planning", "Establish, maintain and test plans for emergency response, backup operations and post-disaster recovery to ensure the availability of critical information resources and continuity of operations.", nist80053Source + "CP"}, + {"IA", "Identification and Authentication", "Uniquely identify and authenticate organizational users, processes and devices before granting access to systems.", nist80053Source + "IA"}, + {"IR", "Incident Response", "Establish an operational incident-handling capability — preparation, detection, analysis, containment, eradication and recovery — and track, document and report incidents.", nist80053Source + "IR"}, + {"MA", "Maintenance", "Perform periodic and timely maintenance on systems and provide effective controls on the tools, techniques, mechanisms and personnel used to conduct maintenance.", nist80053Source + "MA"}, + {"MP", "Media Protection", "Protect system media (digital and non-digital), limit access to authorized users, and sanitize or destroy media before disposal or reuse.", nist80053Source + "MP"}, + {"PE", "Physical and Environmental Protection", "Limit physical access to systems, equipment and operating environments to authorized individuals, and protect against environmental hazards and supporting utilities failures.", nist80053Source + "PE"}, + {"PL", "Planning", "Develop, document and maintain security and privacy plans that describe the controls in place or planned and the rules of behaviour for individuals accessing systems.", nist80053Source + "PL"}, + {"PM", "Program Management", "Implement organization-wide information security and privacy program management controls, including a risk management strategy, resources and enterprise architecture.", nist80053Source + "PM"}, + {"PS", "Personnel Security", "Ensure individuals occupying positions of responsibility are trustworthy, apply screening, and protect systems during personnel actions such as transfers and terminations.", nist80053Source + "PS"}, + {"PT", "PII Processing and Transparency", "Process personally identifiable information in accordance with authority and privacy requirements, and provide transparency to individuals about that processing.", nist80053Source + "PT"}, + {"RA", "Risk Assessment", "Assess the risk to organizational operations, assets and individuals resulting from the operation of systems, including vulnerability scanning and the processing of PII.", nist80053Source + "RA"}, + {"SA", "System and Services Acquisition", "Allocate resources to protect systems, employ a system development life cycle with security and privacy considerations, and manage acquired services and supply.", nist80053Source + "SA"}, + {"SC", "System and Communications Protection", "Monitor, control and protect communications at system boundaries and employ architectural designs, software development techniques and systems engineering to promote security.", nist80053Source + "SC"}, + {"SI", "System and Information Integrity", "Identify, report and correct system flaws in a timely manner, protect against malicious code, and monitor systems for security alerts and advisories.", nist80053Source + "SI"}, + {"SR", "Supply Chain Risk Management", "Manage supply chain risks by developing a strategy, applying provenance and integrity controls, and assessing suppliers and the products and services they provide.", nist80053Source + "SR"}, +} diff --git a/backend/pkg/compliance/catalog_sox_2002.go b/backend/pkg/compliance/catalog_sox_2002.go new file mode 100644 index 00000000..9ce4cabd --- /dev/null +++ b/backend/pkg/compliance/catalog_sox_2002.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// SOX — the Sarbanes-Oxley Act of 2002. From a GRC/IT perspective, SOX compliance +// centers on internal control over financial reporting (ICFR) — the key statutory +// sections (302, 404, 409, 802, 806, 906) plus the IT General Controls (ITGC) +// domains auditors assess under the COSO framework. Section numbers are the Act's +// own public structure and the ITGC domains are the widely-recognized categories; +// descriptions are original summaries, not legal text. Verify against the Act and +// your auditor's control matrix before an audit. + +func init() { + register(Catalog{ + Key: "sox-2002", + Name: "SOX (Sarbanes-Oxley)", + Version: "2002", + Description: "Sarbanes-Oxley Act — internal control over financial reporting: the key statutory sections and the IT General Controls (access, change management, operations, program development) auditors assess.", + Available: true, + Controls: sox2002Controls, + }) +} + +const ( + soxSecSource = "Sarbanes-Oxley Act of 2002, Section " + soxITGCSource = "SOX ITGC (COSO), Domain " +) + +var sox2002Controls = []CatalogControl{ + // Key statutory sections + {"SOX-302", "Corporate Responsibility for Financial Reports", "Principal officers certify each periodic report — its accuracy, the effectiveness of disclosure controls, and disclosure of deficiencies and fraud to auditors and the audit committee.", soxSecSource + "302"}, + {"SOX-404", "Management Assessment of Internal Controls", "Management establishes, documents, assesses and reports on the effectiveness of internal control over financial reporting, with independent auditor attestation.", soxSecSource + "404"}, + {"SOX-409", "Real Time Issuer Disclosures", "Disclose to the public, on a rapid and current basis, material changes in financial condition or operations.", soxSecSource + "409"}, + {"SOX-802", "Criminal Penalties for Altering Documents", "Retain records and audit work papers, and prohibit the alteration, destruction or falsification of records relevant to financial reporting.", soxSecSource + "802"}, + {"SOX-806", "Protection for Whistleblowers", "Protect employees who report fraud or violations of securities law from retaliation, and provide a confidential reporting mechanism.", soxSecSource + "806"}, + {"SOX-906", "Corporate Responsibility for Financial Reports (Certification)", "Principal executive and financial officers certify that periodic reports fully comply with securities law and fairly present the financial condition of the issuer.", soxSecSource + "906"}, + + // IT General Controls (ITGC) domains + {"ITGC-AC", "Access to Programs and Data", "Restrict logical access to financial applications, databases and infrastructure to authorized users through provisioning, periodic review, segregation of duties and privileged-access controls.", soxITGCSource + "Access to Programs and Data"}, + {"ITGC-CM", "Program Changes", "Authorize, test, approve and migrate changes to financially-relevant systems through a controlled change management process that separates development from production.", soxITGCSource + "Program Changes"}, + {"ITGC-PD", "Program Development", "Govern the acquisition and development of new financially-relevant systems with documented requirements, testing, approval and data-conversion controls.", soxITGCSource + "Program Development"}, + {"ITGC-OP", "Computer Operations", "Control the operation of financially-relevant systems: job scheduling, backup and recovery, incident/problem management and physical/environmental protection of infrastructure.", soxITGCSource + "Computer Operations"}, +} diff --git a/backend/pkg/compliance/catalog_test.go b/backend/pkg/compliance/catalog_test.go index 542267df..7f61838a 100644 --- a/backend/pkg/compliance/catalog_test.go +++ b/backend/pkg/compliance/catalog_test.go @@ -67,6 +67,14 @@ func TestExpectedControlCounts(t *testing.T) { "pci-dss-4.0": 12, // 12 core requirements "hipaa-security": 22, // Administrative(9)+Physical(4)+Technical(5)+Organizational(2)+Docs(2) "soc2-tsc": 51, // Common Criteria(33)+A(3)+C(2)+PI(5)+P(8) + // International frameworks added for the "5. Conformité" spec — full target list. + "iso27005-2022": 19, // Process clauses 5,6,7,8,9,10 activities + "iso31000-2018": 22, // 8 principles + 6 framework components + 8 process activities + "nist-800-53-r5": 20, // 20 control families (AC…SR) + "gdpr-2016-679": 22, // key operational articles (principles, rights, security, DPO, transfers) + "dora-2022-2554": 19, // 5 pillars — key articles + "nis2-2022-2555": 12, // governance + the 10 art.21 measures + notification + "sox-2002": 10, // 6 statutory sections + 4 ITGC domains } for key, want := range cases { t.Run(key, func(t *testing.T) { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5e1d0f2e..6c18a1ee 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -906,6 +906,262 @@ paths: '404': description: Evidence not found + # ==================== GAP ANALYSIS / AUDITS / REMEDIATION / MAPPINGS ==================== + /compliance/gap-analysis: + get: + tags: [Compliance] + summary: Analyse d'écarts — every unsatisfied control across the tenant's frameworks + operationId: getGapAnalysis + security: [{ bearerAuth: [] }] + parameters: + - name: framework_id + in: query + required: false + description: Scope the analysis to a single framework. + schema: { type: string, format: uuid } + responses: + '200': + description: Gap analysis (roll-ups + open gaps). + content: + application/json: + schema: { $ref: '#/components/schemas/GapAnalysis' } + + /compliance/control-mappings: + get: + tags: [Compliance] + summary: List cross-framework control mappings + operationId: listControlMappings + security: [{ bearerAuth: [] }] + parameters: + - name: control_id + in: query + required: false + description: Scope to mappings touching this control (as source or target). + schema: { type: string, format: uuid } + responses: + '200': + description: The tenant's control mappings. + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/ControlMapping' } + post: + tags: [Compliance] + summary: Link two controls across frameworks + operationId: createControlMapping + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateControlMappingInput' } + responses: + '201': + description: Mapping created. + content: + application/json: + schema: { $ref: '#/components/schemas/ControlMapping' } + '400': + description: Validation error (self-link, unknown control, bad relation). + '409': + description: A mapping between these controls already exists. + + /compliance/control-mappings/{mappingId}: + delete: + tags: [Compliance] + summary: Delete a control mapping + operationId: deleteControlMapping + security: [{ bearerAuth: [] }] + parameters: + - name: mappingId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Mapping deleted } + '404': { description: Mapping not found } + + /compliance/audits: + get: + tags: [Compliance] + summary: List a tenant's compliance audits + operationId: listComplianceAudits + security: [{ bearerAuth: [] }] + responses: + '200': + description: The tenant's audits. + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/ComplianceAudit' } + post: + tags: [Compliance] + summary: Plan a compliance audit + operationId: createComplianceAudit + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateAuditInput' } + responses: + '201': + description: Audit created. + content: + application/json: + schema: { $ref: '#/components/schemas/ComplianceAudit' } + '400': { description: Validation error } + + /compliance/audits/{id}: + get: + tags: [Compliance] + summary: Get a compliance audit + operationId: getComplianceAudit + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The audit. + content: + application/json: + schema: { $ref: '#/components/schemas/ComplianceAudit' } + '404': { description: Audit not found } + patch: + tags: [Compliance] + summary: Update a compliance audit (status, dates, summary…) + operationId: updateComplianceAudit + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateAuditInput' } + responses: + '200': + description: The updated audit. + content: + application/json: + schema: { $ref: '#/components/schemas/ComplianceAudit' } + '404': { description: Audit not found } + delete: + tags: [Compliance] + summary: Delete a compliance audit + operationId: deleteComplianceAudit + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Audit deleted } + '404': { description: Audit not found } + + /compliance/audits/{id}/generate-remediations: + post: + tags: [Compliance] + summary: Auto-generate remediation plans for the gaps under an audit's framework + operationId: generateRemediationsFromAudit + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '201': + description: Remediation plans created (idempotent — skips gaps already covered). + content: + application/json: + schema: { $ref: '#/components/schemas/GenerateRemediationsResult' } + '400': { description: Audit is program-wide (no framework) } + '404': { description: Audit not found } + + /compliance/remediations: + get: + tags: [Compliance] + summary: List a tenant's remediation plans + operationId: listRemediations + security: [{ bearerAuth: [] }] + parameters: + - { name: control_id, in: query, required: false, schema: { type: string, format: uuid } } + - { name: framework_id, in: query, required: false, schema: { type: string, format: uuid } } + - { name: audit_id, in: query, required: false, schema: { type: string, format: uuid } } + - { name: status, in: query, required: false, schema: { type: string, enum: [open, in_progress, completed, cancelled] } } + responses: + '200': + description: The tenant's remediation plans. + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/RemediationPlan' } + post: + tags: [Compliance] + summary: Create a remediation plan + operationId: createRemediation + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateRemediationInput' } + responses: + '201': + description: Remediation plan created. + content: + application/json: + schema: { $ref: '#/components/schemas/RemediationPlan' } + '400': { description: Validation error } + + /compliance/remediations/{id}: + patch: + tags: [Compliance] + summary: Update a remediation plan (status, assignee, due date…) + operationId: updateRemediation + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateRemediationInput' } + responses: + '200': + description: The updated remediation plan. + content: + application/json: + schema: { $ref: '#/components/schemas/RemediationPlan' } + '404': { description: Remediation plan not found } + delete: + tags: [Compliance] + summary: Delete a remediation plan + operationId: deleteRemediation + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Remediation plan deleted } + '404': { description: Remediation plan not found } + # ==================== BOARD REPORT (M4) ==================== /reports/board: get: @@ -1893,6 +2149,188 @@ components: format: float description: implemented / applicable * 100 (0 if applicable is 0) + GapControl: + type: object + description: One unsatisfied control (neither implemented nor not_applicable). + required: [control_id, framework_id, framework_name, reference_code, name, description, status, source_reference, evidence_count] + properties: + control_id: { type: string, format: uuid } + framework_id: { type: string, format: uuid } + framework_name: { type: string } + reference_code: { type: string } + name: { type: string } + description: { type: string } + status: { type: string, enum: [not_implemented, in_progress, implemented, not_applicable] } + source_reference: { type: string } + evidence_count: { type: integer } + + FrameworkGapSummary: + type: object + description: Per-framework gap roll-up. + required: [framework_id, framework_name, version, total, implemented, in_progress, not_implemented, not_applicable, gaps, percent_complete] + properties: + framework_id: { type: string, format: uuid } + framework_name: { type: string } + version: { type: string } + total: { type: integer } + implemented: { type: integer } + in_progress: { type: integer } + not_implemented: { type: integer } + not_applicable: { type: integer } + gaps: { type: integer } + percent_complete: { type: number, format: float } + + GapAnalysis: + type: object + description: > + Computed, not persisted — every open compliance gap across a tenant's + frameworks (or one framework), with per-framework and overall roll-ups. + required: [total_controls, total_gaps, frameworks, gaps] + properties: + total_controls: { type: integer } + total_gaps: { type: integer } + frameworks: + type: array + items: { $ref: '#/components/schemas/FrameworkGapSummary' } + gaps: + type: array + items: { $ref: '#/components/schemas/GapControl' } + + ComplianceAudit: + type: object + description: A tenant-scoped compliance audit (planning, execution, history). + required: [id, tenant_id, framework_id, title, type, status, auditor, scope, summary, compliance_score, scheduled_start, scheduled_end, completed_at, created_by, created_at, updated_at] + properties: + id: { type: string, format: uuid } + tenant_id: { type: string, format: uuid } + framework_id: { type: string, format: uuid, nullable: true, description: null for a program-wide audit } + title: { type: string } + type: { type: string, enum: [internal, external, certification, surveillance] } + status: { type: string, enum: [planned, in_progress, completed, cancelled] } + auditor: { type: string } + scope: { type: string } + summary: { type: string } + compliance_score: { type: number, format: float, description: compliance % snapshotted when the audit completes } + scheduled_start: { type: string, format: date-time, nullable: true } + scheduled_end: { type: string, format: date-time, nullable: true } + completed_at: { type: string, format: date-time, nullable: true } + created_by: { type: string, format: uuid, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + CreateAuditInput: + type: object + required: [title, type] + properties: + title: { type: string } + type: { type: string, enum: [internal, external, certification, surveillance] } + framework_id: { type: string, format: uuid } + auditor: { type: string } + scope: { type: string } + scheduled_start: { type: string, format: date-time } + scheduled_end: { type: string, format: date-time } + + UpdateAuditInput: + type: object + properties: + title: { type: string } + type: { type: string, enum: [internal, external, certification, surveillance] } + status: { type: string, enum: [planned, in_progress, completed, cancelled] } + auditor: { type: string } + scope: { type: string } + summary: { type: string } + compliance_score: { type: number, format: float } + scheduled_start: { type: string, format: date-time } + scheduled_end: { type: string, format: date-time } + + RemediationPlan: + type: object + description: A remediation action to close a compliance gap, linked to a control/audit. + required: [id, tenant_id, title, description, control_id, framework_id, audit_id, priority, status, assigned_to, due_date, completed_at, created_by, created_at, updated_at] + properties: + id: { type: string, format: uuid } + tenant_id: { type: string, format: uuid } + title: { type: string } + description: { type: string } + control_id: { type: string, format: uuid, nullable: true } + framework_id: { type: string, format: uuid, nullable: true } + audit_id: { type: string, format: uuid, nullable: true } + priority: { type: string, enum: [low, medium, high, critical] } + status: { type: string, enum: [open, in_progress, completed, cancelled] } + assigned_to: { type: string, format: uuid, nullable: true } + due_date: { type: string, format: date-time, nullable: true } + completed_at: { type: string, format: date-time, nullable: true } + created_by: { type: string, format: uuid, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + control_code: { type: string } + control_name: { type: string } + + CreateRemediationInput: + type: object + required: [title] + properties: + title: { type: string } + description: { type: string } + control_id: { type: string, format: uuid } + audit_id: { type: string, format: uuid } + priority: { type: string, enum: [low, medium, high, critical] } + assigned_to: { type: string, format: uuid } + due_date: { type: string, format: date-time } + + UpdateRemediationInput: + type: object + properties: + title: { type: string } + description: { type: string } + priority: { type: string, enum: [low, medium, high, critical] } + status: { type: string, enum: [open, in_progress, completed, cancelled] } + assigned_to: { type: string, format: uuid } + due_date: { type: string, format: date-time } + + GenerateRemediationsResult: + type: object + description: Result of auto-generating remediation plans from a completed audit. + required: [created, skipped, plans] + properties: + created: { type: integer } + skipped: { type: integer } + plans: + type: array + items: { $ref: '#/components/schemas/RemediationPlan' } + + ControlMapping: + type: object + description: A tenant-scoped, undirected crosswalk between two controls across frameworks. + required: [id, tenant_id, source_control_id, target_control_id, relation, note, created_by, created_at, updated_at] + properties: + id: { type: string, format: uuid } + tenant_id: { type: string, format: uuid } + source_control_id: { type: string, format: uuid } + target_control_id: { type: string, format: uuid } + relation: { type: string, enum: [equivalent, partial, related] } + note: { type: string } + created_by: { type: string, format: uuid, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + source_code: { type: string } + source_name: { type: string } + source_framework_id: { type: string } + source_framework_name: { type: string } + target_code: { type: string } + target_name: { type: string } + target_framework_id: { type: string } + target_framework_name: { type: string } + + CreateControlMappingInput: + type: object + required: [source_control_id, target_control_id] + properties: + source_control_id: { type: string, format: uuid } + target_control_id: { type: string, format: uuid } + relation: { type: string, enum: [equivalent, partial, related] } + note: { type: string } + FrameworkSnapshot: type: object description: Per-framework advancement frozen into a board report at generation time. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18de7750..5634e2f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,9 @@ import { VulnerabilitiesPage } from './features/vulnerabilities/VulnerabilitiesP import { MitigationsBoard } from './features/mitigations/MitigationsBoard'; import { ComplianceScreen } from './features/compliance/ComplianceScreen'; import { FrameworkDetail } from './features/compliance/FrameworkDetail'; +import { GapAnalysisPage } from './features/compliance/GapAnalysisPage'; +import { AuditsPage } from './features/compliance/AuditsPage'; +import { RemediationPage } from './features/compliance/RemediationPage'; import { InventoryPage } from './features/assets/InventoryPage'; import { AssetUniverse } from './features/universe/AssetUniverse'; import { AnalyticsCiso } from './features/analytics/AnalyticsCiso'; @@ -139,6 +142,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/compliance/AuditRemediationModals.tsx b/frontend/src/features/compliance/AuditRemediationModals.tsx new file mode 100644 index 00000000..d17f5238 --- /dev/null +++ b/frontend/src/features/compliance/AuditRemediationModals.tsx @@ -0,0 +1,166 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// +// Dialogs for compliance audits and remediation plans, in the dc.html design +// language. Reuse the shared modal primitives from ComplianceModals.tsx. + +import { useState } from 'react'; +import { CalendarClock, Wrench } from 'lucide-react'; +import { toast } from 'sonner'; +import { useUIStore } from '../../store/uiStore'; +import { ModalShell, Field, SelectField, TextArea, FooterButtons } from './ComplianceModals'; +import { useAudits, useFrameworks, useRemediations } from './useCompliance'; +import type { AuditType, RemediationPriority } from '../../types/compliance'; + +function useTr() { + const lang = useUIStore((s) => s.lang); + return (fr: string, en: string) => (lang === 'fr' ? fr : en); +} + +function errMsg(err: unknown, fallback: string): string { + const e = err as { response?: { data?: { error?: string } } }; + return e?.response?.data?.error || fallback; +} + +/* ------------------------------------------------------------------ */ +/* Schedule an audit */ +/* ------------------------------------------------------------------ */ + +export function CreateAuditDialog({ onClose, onCreated }: { onClose: () => void; onCreated?: (id: string) => void }) { + const tr = useTr(); + const { createAudit } = useAudits(); + const { frameworks } = useFrameworks(); + const [title, setTitle] = useState(''); + const [type, setType] = useState('internal'); + const [frameworkId, setFrameworkId] = useState(''); + const [auditor, setAuditor] = useState(''); + const [scope, setScope] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [error, setError] = useState(''); + + const typeOptions = [ + { value: 'internal', label: tr('Interne', 'Internal') }, + { value: 'external', label: tr('Externe', 'External') }, + { value: 'certification', label: tr('Certification', 'Certification') }, + { value: 'surveillance', label: tr('Surveillance', 'Surveillance') }, + ]; + const fwOptions = [{ value: '', label: tr('Programme entier', 'Whole program') }, ...frameworks.map((f) => ({ value: f.id, label: `${f.name}${f.version ? ` ${f.version}` : ''}` }))]; + + const submit = () => { + if (title.trim().length < 2) { + setError(tr('Le titre doit comporter au moins 2 caractères.', 'Title must be at least 2 characters.')); + return; + } + createAudit.mutate( + { + title: title.trim(), + type, + framework_id: frameworkId || undefined, + auditor: auditor.trim() || undefined, + scope: scope.trim() || undefined, + scheduled_start: start || undefined, + scheduled_end: end || undefined, + }, + { + onSuccess: (a) => { + toast.success(tr('Audit planifié', 'Audit scheduled')); + onCreated?.(a.id); + onClose(); + }, + onError: (err) => toast.error(errMsg(err, tr('Création échouée', 'Creation failed'))), + } + ); + }; + + return ( + } onClose={onClose} onSubmit={submit} + footer={}> + { setTitle(v); setError(''); }} required autoFocus + placeholder={tr('ex. Audit interne ISO 27001 Q3', 'e.g. ISO 27001 internal audit Q3')} error={error} /> +
+ setType(v as AuditType)} options={typeOptions} /> + +
+ +
+ + +
+