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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- 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
|
+
}
|