admina-framework 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,331 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina — GDPR base hub.
16
+
17
+ Two primitives required by GDPR Art. 30 and Art. 35:
18
+
19
+ - :class:`ProcessingActivitiesRegistry` — Art. 30 records of
20
+ processing activities (RoPA): typed CRUD over JSON-on-disk.
21
+ A single-host, single-controller registry; multi-tenant /
22
+ multi-controller / role-based workflows are out of scope for
23
+ this release.
24
+
25
+ - :func:`render_dpia_template` — Art. 35 DPIA scaffold rendered
26
+ as Markdown from the operator's input. The OSS module produces
27
+ a *blank template populated with the operator's facts* — it is
28
+ NOT a guided wizard, it does NOT score or recommend mitigations,
29
+ and it is NOT legal advice. A real DPIA always involves the DPO
30
+ and (for high-risk processing) the supervisory authority.
31
+
32
+ Out-of-scope (intentionally):
33
+ - Guided DPIA wizard with risk scoring and mitigation suggestions
34
+ - Pre-curated RoPA templates per sector
35
+ - Workflow for Data Subject Requests (Art. 12-22)
36
+ - Records of consent (Art. 6/7)
37
+ - Automated TIA (Transfer Impact Assessment) under Schrems II
38
+ - Branded board-ready reporting
39
+
40
+ References:
41
+ - Regulation (EU) 2016/679 (GDPR)
42
+ - EDPB Guidelines 4/2019 on Art. 25 (data protection by design and default)
43
+ - WP29 WP248 rev.01 (DPIA guidelines)
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import logging
50
+ import uuid
51
+ from dataclasses import asdict, dataclass, field
52
+ from datetime import UTC, datetime
53
+ from pathlib import Path
54
+
55
+ logger = logging.getLogger("admina.gdpr")
56
+
57
+
58
+ # Recital 90 + WP29: criteria triggering "high risk" likely to require a DPIA.
59
+ DPIA_REQUIRED_CRITERIA: list[str] = [
60
+ "evaluation_or_scoring",
61
+ "automated_decision_with_legal_effect",
62
+ "systematic_monitoring",
63
+ "sensitive_or_highly_personal_data",
64
+ "data_processed_on_a_large_scale",
65
+ "matching_or_combining_datasets",
66
+ "data_concerning_vulnerable_data_subjects",
67
+ "innovative_use_or_applying_new_technological_solutions",
68
+ "blocking_a_right_or_a_service_or_a_contract",
69
+ ]
70
+
71
+
72
+ @dataclass
73
+ class ProcessingActivity:
74
+ """Art. 30(1) record of processing activities — minimum content."""
75
+
76
+ id: str = ""
77
+ name: str = ""
78
+ purpose: str = ""
79
+ legal_basis: str = "" # Art. 6(1) basis: consent | contract | legal_obligation | vital_interests | public_task | legitimate_interests
80
+ data_categories: list[str] = field(default_factory=list)
81
+ data_subjects: list[str] = field(default_factory=list)
82
+ recipients: list[str] = field(default_factory=list)
83
+ third_country_transfers: list[str] = field(default_factory=list)
84
+ retention_period: str = ""
85
+ technical_security_measures: list[str] = field(default_factory=list)
86
+ organizational_security_measures: list[str] = field(default_factory=list)
87
+ controller: str = ""
88
+ dpo_contact: str = ""
89
+ created_at: str = ""
90
+ updated_at: str = ""
91
+
92
+ def __post_init__(self) -> None:
93
+ if not self.id:
94
+ self.id = str(uuid.uuid4())
95
+ now = datetime.now(UTC).isoformat()
96
+ if not self.created_at:
97
+ self.created_at = now
98
+ self.updated_at = now
99
+
100
+
101
+ class ProcessingActivitiesRegistry:
102
+ """Single-controller RoPA store backed by a JSON file on disk.
103
+
104
+ The registry is intentionally minimal: a flat list of activities,
105
+ no per-record permissions, no audit trail beyond the natural
106
+ forensic_box (when used through the proxy). Multi-tenant /
107
+ multi-controller / role-based workflows are out of scope for
108
+ this release.
109
+ """
110
+
111
+ def __init__(self, storage_path: str | Path | None = None) -> None:
112
+ # No filesystem default. The registry only persists if the
113
+ # operator opts in by passing storage_path explicitly OR by
114
+ # setting ADMINA_GDPR_ROPA_PATH in the environment. Without
115
+ # either, the registry is in-memory only — events live for
116
+ # the process lifetime, never silently written to disk.
117
+ if storage_path is None:
118
+ import os as _os
119
+
120
+ env_path = _os.environ.get("ADMINA_GDPR_ROPA_PATH")
121
+ if env_path:
122
+ storage_path = env_path
123
+ self.storage_path = Path(storage_path) if storage_path else None
124
+ if self.storage_path is not None:
125
+ try:
126
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
127
+ except OSError as exc:
128
+ logger.warning(
129
+ "GDPR RoPA path %s not writable (%s) — falling back to in-memory",
130
+ self.storage_path,
131
+ exc,
132
+ )
133
+ self.storage_path = None
134
+ self._activities: dict[str, ProcessingActivity] = {}
135
+ self._load()
136
+
137
+ # ── persistence ──────────────────────────────────────────────
138
+ def _load(self) -> None:
139
+ if self.storage_path is None or not self.storage_path.exists():
140
+ return
141
+ try:
142
+ data = json.loads(self.storage_path.read_text(encoding="utf-8"))
143
+ for entry in data:
144
+ act = ProcessingActivity(**entry)
145
+ self._activities[act.id] = act
146
+ except (OSError, json.JSONDecodeError, TypeError) as exc:
147
+ logger.warning("Failed to load RoPA from %s: %s", self.storage_path, exc)
148
+
149
+ def _save(self) -> None:
150
+ if self.storage_path is None:
151
+ return # in-memory mode
152
+ try:
153
+ data = [asdict(a) for a in self._activities.values()]
154
+ self.storage_path.write_text(
155
+ json.dumps(data, indent=2, ensure_ascii=False),
156
+ encoding="utf-8",
157
+ )
158
+ except OSError as exc:
159
+ logger.warning("Failed to persist RoPA to %s: %s", self.storage_path, exc)
160
+
161
+ # ── CRUD ────────────────────────────────────────────────────
162
+ def list(self) -> list[dict]:
163
+ return [asdict(a) for a in self._activities.values()]
164
+
165
+ def get(self, activity_id: str) -> dict | None:
166
+ act = self._activities.get(activity_id)
167
+ return asdict(act) if act else None
168
+
169
+ def create(self, payload: dict) -> dict:
170
+ # Strip server-managed fields if the client tries to set them
171
+ for k in ("id", "created_at", "updated_at"):
172
+ payload.pop(k, None)
173
+ act = ProcessingActivity(**payload)
174
+ self._activities[act.id] = act
175
+ self._save()
176
+ return asdict(act)
177
+
178
+ def update(self, activity_id: str, payload: dict) -> dict | None:
179
+ existing = self._activities.get(activity_id)
180
+ if existing is None:
181
+ return None
182
+ merged = asdict(existing)
183
+ merged.update(payload)
184
+ merged["id"] = existing.id
185
+ merged["created_at"] = existing.created_at
186
+ merged["updated_at"] = datetime.now(UTC).isoformat()
187
+ # Re-instantiate to validate / normalise via __post_init__
188
+ new_act = ProcessingActivity(**merged)
189
+ new_act.created_at = existing.created_at
190
+ new_act.updated_at = merged["updated_at"]
191
+ self._activities[activity_id] = new_act
192
+ self._save()
193
+ return asdict(new_act)
194
+
195
+ def delete(self, activity_id: str) -> bool:
196
+ if activity_id in self._activities:
197
+ del self._activities[activity_id]
198
+ self._save()
199
+ return True
200
+ return False
201
+
202
+ def get_stats(self) -> dict:
203
+ total = len(self._activities)
204
+ by_basis: dict[str, int] = {}
205
+ with_transfers = 0
206
+ for a in self._activities.values():
207
+ by_basis[a.legal_basis or "(unspecified)"] = (
208
+ by_basis.get(a.legal_basis or "(unspecified)", 0) + 1
209
+ )
210
+ if a.third_country_transfers:
211
+ with_transfers += 1
212
+ return {
213
+ "total_activities": total,
214
+ "by_legal_basis": by_basis,
215
+ "with_third_country_transfers": with_transfers,
216
+ "storage_path": str(self.storage_path) if self.storage_path else None,
217
+ "persistence": "filesystem" if self.storage_path else "in-memory",
218
+ }
219
+
220
+
221
+ def render_dpia_template(payload: dict) -> str:
222
+ """Render a Markdown DPIA scaffold from operator-supplied facts.
223
+
224
+ The OSS template is intentionally a *scaffold*, not a guided
225
+ wizard. It populates the standard sections of an Art. 35 DPIA
226
+ with the values the operator passed in, leaves placeholders
227
+ for everything else, and ends with the standard caveat that a
228
+ real DPIA requires DPO involvement.
229
+
230
+ Expected payload keys (all optional, all string except as noted):
231
+ processing_name, controller, dpo_contact, processor,
232
+ purposes, legal_basis, data_categories (list), data_subjects (list),
233
+ recipients (list), third_countries (list), retention_period,
234
+ necessity_proportionality_assessment,
235
+ identified_risks (list of {risk, likelihood, severity, mitigation}),
236
+ consultation_dpo (str), consultation_data_subjects (str)
237
+ """
238
+ p = payload or {}
239
+
240
+ def _list(key: str) -> str:
241
+ items = p.get(key) or []
242
+ if not items:
243
+ return "_TBD_"
244
+ return "\n".join(f"- {item}" for item in items)
245
+
246
+ def _scalar(key: str, default: str = "_TBD_") -> str:
247
+ return str(p.get(key) or default)
248
+
249
+ risks = p.get("identified_risks") or []
250
+ risk_block = "_None recorded — TBD_"
251
+ if risks:
252
+ rows = [
253
+ "| # | Risk | Likelihood | Severity | Mitigation |",
254
+ "|---|------|------------|----------|------------|",
255
+ ]
256
+ for i, r in enumerate(risks, 1):
257
+ rows.append(
258
+ f"| {i} | {r.get('risk', '_TBD_')} "
259
+ f"| {r.get('likelihood', '_TBD_')} "
260
+ f"| {r.get('severity', '_TBD_')} "
261
+ f"| {r.get('mitigation', '_TBD_')} |"
262
+ )
263
+ risk_block = "\n".join(rows)
264
+
265
+ return f"""# Data Protection Impact Assessment (DPIA)
266
+
267
+ > Template generated by Admina at {datetime.now(UTC).isoformat()}.
268
+ > This is a **scaffold**, not legal advice. A real DPIA under
269
+ > GDPR Art. 35 requires DPO involvement (Art. 39) and may require
270
+ > consultation of the supervisory authority (Art. 36).
271
+
272
+ ## 1. Identification
273
+
274
+ - **Processing name:** {_scalar("processing_name")}
275
+ - **Controller:** {_scalar("controller")}
276
+ - **Data Protection Officer (contact):** {_scalar("dpo_contact")}
277
+ - **Processor(s):** {_scalar("processor")}
278
+
279
+ ## 2. Description of the processing
280
+
281
+ ### Purposes
282
+ {_scalar("purposes")}
283
+
284
+ ### Legal basis (Art. 6 / 9 / 10)
285
+ {_scalar("legal_basis")}
286
+
287
+ ### Categories of personal data
288
+ {_list("data_categories")}
289
+
290
+ ### Categories of data subjects
291
+ {_list("data_subjects")}
292
+
293
+ ### Recipients (internal and external)
294
+ {_list("recipients")}
295
+
296
+ ### International transfers
297
+ {_list("third_countries")}
298
+
299
+ ### Retention
300
+ {_scalar("retention_period")}
301
+
302
+ ## 3. Necessity and proportionality (Art. 35(7)(b))
303
+ {_scalar("necessity_proportionality_assessment")}
304
+
305
+ ## 4. Risks to the rights and freedoms of data subjects (Art. 35(7)(c))
306
+ {risk_block}
307
+
308
+ ## 5. Measures envisaged (Art. 35(7)(d))
309
+
310
+ _To be completed by the controller / DPO. Should reference both the
311
+ technical and organisational measures already in place in the RoPA
312
+ record for this processing._
313
+
314
+ ## 6. Consultation
315
+
316
+ - **DPO opinion (Art. 35(2)):** {_scalar("consultation_dpo")}
317
+ - **Data subjects' views, where appropriate (Art. 35(9)):** {_scalar("consultation_data_subjects")}
318
+
319
+ ## 7. Outcome
320
+
321
+ - [ ] Residual risk acceptable — no further action required
322
+ - [ ] Residual risk requires consultation of the supervisory
323
+ authority under Art. 36
324
+ - [ ] Processing must not proceed in its current form
325
+
326
+ ---
327
+
328
+ *Generated by Admina v0.x — `domains.compliance.gdpr.render_dpia_template`.
329
+ This file is a scaffold to support the controller; it does not
330
+ substitute for the analysis to be performed by the DPO.*
331
+ """
@@ -0,0 +1,258 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina — NIS2 base self-assessment.
16
+
17
+ Provides a deterministic checklist + gap analysis for NIS2 (Directive
18
+ (EU) 2022/2555) Art. 21 cybersecurity risk-management measures and
19
+ Art. 23 incident reporting.
20
+
21
+ Scope of this OSS module: a *triage tool*. It enumerates the ten
22
+ measure areas required by Art. 21 and lets the operator declare for
23
+ each one which of a small number of standard controls is in place,
24
+ producing a coverage score and a list of gaps.
25
+
26
+ Out of scope (intentionally — this is the territory of dedicated
27
+ GRC tooling, not a framework primitive):
28
+ - Incident reporting workflow (24h early warning + 72h notification
29
+ + 1-month report) — needs human-in-the-loop and CSIRT routing
30
+ - Pre-curated control templates per sector (energy, transport,
31
+ health, finance, ...) — need expert-reviewed content
32
+ - Mapping to specific national transposition acts
33
+ - Board-ready PDF reporting
34
+
35
+ References:
36
+ - Directive (EU) 2022/2555 (NIS2)
37
+ - ENISA "Implementation Guide for NIS 2 Risk Management Measures"
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from datetime import UTC, datetime
44
+ from typing import Any
45
+
46
+ logger = logging.getLogger("admina.nis2")
47
+
48
+
49
+ # Art. 21(2) — minimum cybersecurity risk-management measures.
50
+ # Each area carries a short list of the standard controls associated
51
+ # with it. The operator declares one boolean per control; coverage =
52
+ # true_count / total_count.
53
+ NIS2_AREAS: dict[str, dict[str, Any]] = {
54
+ "risk_analysis_and_security_policy": {
55
+ "title": "Policies on risk analysis and information system security",
56
+ "article": "Art. 21(2)(a)",
57
+ "controls": [
58
+ "documented_risk_analysis",
59
+ "approved_information_security_policy",
60
+ "policy_reviewed_annually",
61
+ "scope_includes_supply_chain",
62
+ ],
63
+ },
64
+ "incident_handling": {
65
+ "title": "Incident handling",
66
+ "article": "Art. 21(2)(b)",
67
+ "controls": [
68
+ "incident_response_plan_in_place",
69
+ "responsible_team_designated",
70
+ "lessons_learned_loop",
71
+ "incident_classification_scheme",
72
+ ],
73
+ },
74
+ "business_continuity": {
75
+ "title": "Business continuity (backup, disaster recovery, crisis management)",
76
+ "article": "Art. 21(2)(c)",
77
+ "controls": [
78
+ "business_continuity_plan_documented",
79
+ "backups_tested_periodically",
80
+ "disaster_recovery_tested_annually",
81
+ "crisis_management_procedure",
82
+ ],
83
+ },
84
+ "supply_chain_security": {
85
+ "title": "Supply chain security",
86
+ "article": "Art. 21(2)(d)",
87
+ "controls": [
88
+ "supplier_inventory_maintained",
89
+ "supplier_risk_assessed",
90
+ "contractual_security_clauses",
91
+ "supplier_incident_notification_clause",
92
+ ],
93
+ },
94
+ "secure_acquisition_development": {
95
+ "title": "Security in network and IS acquisition, development, and maintenance",
96
+ "article": "Art. 21(2)(e)",
97
+ "controls": [
98
+ "secure_development_lifecycle",
99
+ "vulnerability_handling_policy",
100
+ "change_management_procedure",
101
+ "security_in_procurement_requirements",
102
+ ],
103
+ },
104
+ "effectiveness_assessment": {
105
+ "title": "Policies and procedures to assess effectiveness of measures",
106
+ "article": "Art. 21(2)(f)",
107
+ "controls": [
108
+ "internal_audit_program",
109
+ "external_audit_or_certification",
110
+ "metrics_and_kpis_defined",
111
+ "management_review_cycle",
112
+ ],
113
+ },
114
+ "cyber_hygiene_and_training": {
115
+ "title": "Basic cyber hygiene practices and cybersecurity training",
116
+ "article": "Art. 21(2)(g)",
117
+ "controls": [
118
+ "user_awareness_training_annual",
119
+ "phishing_drills",
120
+ "patch_management_policy",
121
+ "least_privilege_enforced",
122
+ ],
123
+ },
124
+ "cryptography": {
125
+ "title": "Policies and procedures regarding the use of cryptography",
126
+ "article": "Art. 21(2)(h)",
127
+ "controls": [
128
+ "encryption_in_transit",
129
+ "encryption_at_rest",
130
+ "key_management_policy",
131
+ "approved_algorithms_only",
132
+ ],
133
+ },
134
+ "access_control_and_asset_management": {
135
+ "title": "Human resources security, access control policies, asset management",
136
+ "article": "Art. 21(2)(i)",
137
+ "controls": [
138
+ "asset_inventory_maintained",
139
+ "rbac_or_abac_in_place",
140
+ "joiner_mover_leaver_process",
141
+ "privileged_access_review",
142
+ ],
143
+ },
144
+ "mfa_and_secure_communications": {
145
+ "title": "Multi-factor authentication and secure communications",
146
+ "article": "Art. 21(2)(j)",
147
+ "controls": [
148
+ "mfa_for_admin_access",
149
+ "mfa_for_remote_access",
150
+ "secure_voice_video_text_internal",
151
+ "emergency_communication_plan",
152
+ ],
153
+ },
154
+ }
155
+
156
+
157
+ # NIS2 entered into force on 17 January 2023; Member States had to
158
+ # transpose by 17 October 2024. Reference for the dashboard countdown.
159
+ NIS2_TRANSPOSITION_DEADLINE = "2024-10-17"
160
+
161
+
162
+ class NIS2Compliance:
163
+ """Lightweight NIS2 self-assessment engine.
164
+
165
+ Mirrors the shape of :class:`EUAIActCompliance` so the dashboard
166
+ can treat the two compliance regimes uniformly.
167
+ """
168
+
169
+ def __init__(self) -> None:
170
+ self.assessments: list[dict] = []
171
+
172
+ def list_areas(self) -> dict[str, dict[str, Any]]:
173
+ """Return the canonical area catalogue (id → metadata)."""
174
+ return {k: dict(v) for k, v in NIS2_AREAS.items()}
175
+
176
+ def assess(self, current_compliance: dict[str, list[bool]]) -> dict:
177
+ """Run a coverage assessment.
178
+
179
+ Args:
180
+ current_compliance: ``{area_id: [True/False, ...]}`` — one
181
+ boolean per control in the area's ``controls`` list.
182
+ Missing areas are treated as fully un-implemented (all
183
+ False) so partial submissions still produce an honest
184
+ score.
185
+
186
+ Returns:
187
+ ``{
188
+ applicable: bool,
189
+ coverage_score: 0..100,
190
+ total_controls: int,
191
+ satisfied_controls: int,
192
+ gaps: [{area, article, missing_controls: [...]}, ...],
193
+ areas: {area_id: {coverage_pct, satisfied, total}},
194
+ assessed_at: ISO8601 UTC,
195
+ status: "FULLY_COMPLIANT" | "GAPS_FOUND",
196
+ }``
197
+ """
198
+ total_controls = 0
199
+ satisfied = 0
200
+ gaps: list[dict] = []
201
+ areas_summary: dict[str, dict[str, Any]] = {}
202
+
203
+ for area_id, meta in NIS2_AREAS.items():
204
+ controls = meta["controls"]
205
+ declared = current_compliance.get(area_id) or [False] * len(controls)
206
+ # Truncate / pad declared to match the canonical control count
207
+ declared = list(declared)[: len(controls)]
208
+ declared += [False] * (len(controls) - len(declared))
209
+
210
+ area_total = len(controls)
211
+ area_sat = sum(1 for v in declared if v)
212
+ total_controls += area_total
213
+ satisfied += area_sat
214
+
215
+ missing = [c for c, v in zip(controls, declared) if not v]
216
+ if missing:
217
+ gaps.append(
218
+ {
219
+ "area": area_id,
220
+ "title": meta["title"],
221
+ "article": meta["article"],
222
+ "missing_controls": missing,
223
+ }
224
+ )
225
+
226
+ areas_summary[area_id] = {
227
+ "title": meta["title"],
228
+ "article": meta["article"],
229
+ "satisfied": area_sat,
230
+ "total": area_total,
231
+ "coverage_pct": round(area_sat * 100 / area_total, 1) if area_total else 0.0,
232
+ }
233
+
234
+ coverage = round(satisfied * 100 / total_controls, 1) if total_controls else 0.0
235
+ result = {
236
+ "applicable": True,
237
+ "coverage_score": coverage,
238
+ "total_controls": total_controls,
239
+ "satisfied_controls": satisfied,
240
+ "gaps": gaps,
241
+ "areas": areas_summary,
242
+ "assessed_at": datetime.now(UTC).isoformat(),
243
+ "status": "FULLY_COMPLIANT" if not gaps else "GAPS_FOUND",
244
+ "transposition_deadline": NIS2_TRANSPOSITION_DEADLINE,
245
+ }
246
+ self.assessments.append(result)
247
+ if len(self.assessments) > 100:
248
+ self.assessments = self.assessments[-100:]
249
+ return result
250
+
251
+ def get_stats(self) -> dict:
252
+ """Aggregate stats for the dashboard / metrics."""
253
+ return {
254
+ "total_assessments": len(self.assessments),
255
+ "areas_count": len(NIS2_AREAS),
256
+ "controls_count": sum(len(a["controls"]) for a in NIS2_AREAS.values()),
257
+ "transposition_deadline": NIS2_TRANSPOSITION_DEADLINE,
258
+ }