dhis2w-security 1.19.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 (81) hide show
  1. dhis2w_security/__init__.py +1 -0
  2. dhis2w_security/core/__init__.py +336 -0
  3. dhis2w_security/core/apps.py +196 -0
  4. dhis2w_security/core/audit_config.py +147 -0
  5. dhis2w_security/core/auth_methods.py +208 -0
  6. dhis2w_security/core/authorities.py +296 -0
  7. dhis2w_security/core/controls.py +233 -0
  8. dhis2w_security/core/credentials.py +82 -0
  9. dhis2w_security/core/csp.py +149 -0
  10. dhis2w_security/core/dhisconf.py +213 -0
  11. dhis2w_security/core/findings.py +73 -0
  12. dhis2w_security/core/guardrails.py +212 -0
  13. dhis2w_security/core/guest.py +129 -0
  14. dhis2w_security/core/hygiene.py +410 -0
  15. dhis2w_security/core/models.py +26 -0
  16. dhis2w_security/core/net.py +174 -0
  17. dhis2w_security/core/options.py +23 -0
  18. dhis2w_security/core/orchestrator.py +73 -0
  19. dhis2w_security/core/registry.py +89 -0
  20. dhis2w_security/core/releases.py +81 -0
  21. dhis2w_security/core/report/__init__.py +1 -0
  22. dhis2w_security/core/report/assets/README.md +44 -0
  23. dhis2w_security/core/report/assets/dhis2-logo.png +0 -0
  24. dhis2w_security/core/report/assets/report.dc.html +565 -0
  25. dhis2w_security/core/report/assets/support.js +1525 -0
  26. dhis2w_security/core/report/base.py +61 -0
  27. dhis2w_security/core/report/csv.py +49 -0
  28. dhis2w_security/core/report/html.py +33 -0
  29. dhis2w_security/core/report/markdown.py +70 -0
  30. dhis2w_security/core/report/model.py +143 -0
  31. dhis2w_security/core/report/progress.py +50 -0
  32. dhis2w_security/core/report/text.py +44 -0
  33. dhis2w_security/core/report/view.py +185 -0
  34. dhis2w_security/core/roles.py +87 -0
  35. dhis2w_security/core/routes.py +201 -0
  36. dhis2w_security/core/settings_audit.py +224 -0
  37. dhis2w_security/core/sharing/__init__.py +99 -0
  38. dhis2w_security/core/sharing/assets/README.md +69 -0
  39. dhis2w_security/core/sharing/assets/d3.min.js +2 -0
  40. dhis2w_security/core/sharing/assets/dhis2-logo.png +0 -0
  41. dhis2w_security/core/sharing/assets/sharing-explorer.html +142 -0
  42. dhis2w_security/core/sharing/assets/sharing-runtime.js +836 -0
  43. dhis2w_security/core/sharing/builder.py +259 -0
  44. dhis2w_security/core/sharing/check.py +167 -0
  45. dhis2w_security/core/sharing/effective.py +160 -0
  46. dhis2w_security/core/sharing/explorer.py +36 -0
  47. dhis2w_security/core/sharing/focus.py +82 -0
  48. dhis2w_security/core/sharing/model.py +329 -0
  49. dhis2w_security/core/sharing/view.py +49 -0
  50. dhis2w_security/core/streaming.py +118 -0
  51. dhis2w_security/core/text.py +11 -0
  52. dhis2w_security/core/tokens.py +243 -0
  53. dhis2w_security/core/transport.py +437 -0
  54. dhis2w_security/core/versions.py +280 -0
  55. dhis2w_security/plugin.py +28 -0
  56. dhis2w_security/v41/__init__.py +1 -0
  57. dhis2w_security/v41/_wire.py +211 -0
  58. dhis2w_security/v41/audit.py +1310 -0
  59. dhis2w_security/v41/cli.py +287 -0
  60. dhis2w_security/v41/mcp.py +64 -0
  61. dhis2w_security/v41/models.py +35 -0
  62. dhis2w_security/v41/service.py +54 -0
  63. dhis2w_security/v42/__init__.py +1 -0
  64. dhis2w_security/v42/_wire.py +197 -0
  65. dhis2w_security/v42/audit.py +1310 -0
  66. dhis2w_security/v42/cli.py +285 -0
  67. dhis2w_security/v42/mcp.py +64 -0
  68. dhis2w_security/v42/models.py +35 -0
  69. dhis2w_security/v42/service.py +54 -0
  70. dhis2w_security/v43/__init__.py +1 -0
  71. dhis2w_security/v43/_wire.py +197 -0
  72. dhis2w_security/v43/audit.py +1310 -0
  73. dhis2w_security/v43/cli.py +285 -0
  74. dhis2w_security/v43/mcp.py +64 -0
  75. dhis2w_security/v43/models.py +35 -0
  76. dhis2w_security/v43/service.py +54 -0
  77. dhis2w_security-1.19.0.dist-info/METADATA +309 -0
  78. dhis2w_security-1.19.0.dist-info/RECORD +81 -0
  79. dhis2w_security-1.19.0.dist-info/WHEEL +4 -0
  80. dhis2w_security-1.19.0.dist-info/entry_points.txt +3 -0
  81. dhis2w_security-1.19.0.dist-info/licenses/LICENSE +15 -0
@@ -0,0 +1 @@
1
+ """The dhis2w-security plugin pack: `d2w security` and the read-only `security_*` MCP tools."""
@@ -0,0 +1,336 @@
1
+ """Version-invariant security domain logic shared by every security plugin tree.
2
+
3
+ The authority taxonomy, severity model, guardrail contract, audit orchestration,
4
+ and report rendering are security opinions, not version concerns: they live once
5
+ here and are consumed by the thin per-tree plugin shims under
6
+ `dhis2w_security.v{41,42,43}`. Only genuinely version-divergent wire
7
+ extraction lives per tree.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dhis2w_security.core.apps import HubApp, InstalledApp, evaluate_apps
13
+ from dhis2w_security.core.audit_config import evaluate_audit_config
14
+ from dhis2w_security.core.auth_methods import LoginProviderView, OAuth2ClientView, evaluate_auth_methods
15
+ from dhis2w_security.core.authorities import (
16
+ AUTHORITY_CATEGORIES,
17
+ AuthorityCategory,
18
+ build_account_authorities,
19
+ categorise_authorities,
20
+ evaluate_account_authorities,
21
+ )
22
+ from dhis2w_security.core.controls import (
23
+ CONTROL_CATALOG,
24
+ CheckOutcome,
25
+ ControlLog,
26
+ ControlOutcome,
27
+ ControlSpec,
28
+ ControlStatus,
29
+ )
30
+ from dhis2w_security.core.credentials import (
31
+ CredentialProbeResult,
32
+ ProbeOutcome,
33
+ classify_probe_status,
34
+ evaluate_credential_probe,
35
+ )
36
+ from dhis2w_security.core.csp import CspDirectives, grade_csp, parse_csp
37
+ from dhis2w_security.core.dhisconf import (
38
+ AuditPosture,
39
+ AuditScopeMatrix,
40
+ RedactedSecret,
41
+ parse_dhis_conf,
42
+ )
43
+ from dhis2w_security.core.findings import (
44
+ HIGH_RISK_ROLE_CATEGORIES,
45
+ SEVERITY_ORDER,
46
+ AuditFinding,
47
+ Severity,
48
+ finding_sort_key,
49
+ role_severity,
50
+ severity_rank,
51
+ )
52
+ from dhis2w_security.core.guardrails import (
53
+ CONNECT_PATHS,
54
+ CREDENTIAL_PROBE_PATHS,
55
+ DEFAULT_PROBE_PASSWORD,
56
+ DEFAULT_PROBE_USERNAME,
57
+ GET_ALLOWLIST,
58
+ MAX_PROBE_ATTEMPTS,
59
+ REPORT_GUARDRAIL_NOTE,
60
+ GuardrailViolation,
61
+ guardrail_request_hook,
62
+ probe_client,
63
+ )
64
+ from dhis2w_security.core.guest import (
65
+ ANONYMOUS_PROBE_TARGETS,
66
+ AnonymousResult,
67
+ GuestProbeTarget,
68
+ evaluate_guest,
69
+ )
70
+ from dhis2w_security.core.hygiene import (
71
+ SEED_USERNAME,
72
+ TwoFactorSource,
73
+ TwoFactorSummary,
74
+ UserHygiene,
75
+ build_user_hygiene,
76
+ evaluate_hygiene,
77
+ evaluate_two_factor_from_endpoint,
78
+ evaluate_two_factor_from_user_field,
79
+ )
80
+ from dhis2w_security.core.models import AccountAuthorities, CategoryMatch
81
+ from dhis2w_security.core.options import AuditOptions
82
+ from dhis2w_security.core.orchestrator import run_audit
83
+ from dhis2w_security.core.registry import (
84
+ CANONICAL_CHECKS,
85
+ BoundCheck,
86
+ CheckSpec,
87
+ canonical_keys,
88
+ label_for,
89
+ resolve_check_keys,
90
+ select_keys,
91
+ )
92
+ from dhis2w_security.core.releases import RELEASES_FEED_URL, ReleaseFeed, ReleaseLine, fetch_release_feed
93
+ from dhis2w_security.core.report.base import ProgressReporter, ReportRenderer, StreamingRenderer
94
+ from dhis2w_security.core.report.csv import CsvRenderer
95
+ from dhis2w_security.core.report.html import HtmlRenderer
96
+ from dhis2w_security.core.report.markdown import MarkdownRenderer
97
+ from dhis2w_security.core.report.model import (
98
+ AuditReport,
99
+ AuditSummary,
100
+ CheckResult,
101
+ CheckStatus,
102
+ RunManifest,
103
+ )
104
+ from dhis2w_security.core.report.progress import PlainLogReporter, RichProgressReporter, make_reporter
105
+ from dhis2w_security.core.report.text import TextRenderer
106
+ from dhis2w_security.core.report.view import (
107
+ ControlView,
108
+ GroupView,
109
+ ItemView,
110
+ ReportMeta,
111
+ ReportView,
112
+ Scorecard,
113
+ SectionView,
114
+ build_report_view,
115
+ )
116
+ from dhis2w_security.core.roles import RoleAudit, build_role_audit, evaluate_roles
117
+ from dhis2w_security.core.routes import RouteTarget, evaluate_routes
118
+ from dhis2w_security.core.settings_audit import (
119
+ MIN_RECOMMENDED_PASSWORD_LENGTH,
120
+ CorsWhitelist,
121
+ SettingsLike,
122
+ evaluate_settings,
123
+ )
124
+ from dhis2w_security.core.sharing import (
125
+ SHARING_CAVEATS,
126
+ SHARING_FOCUS_TYPES,
127
+ SHARING_SCHEMA_VERSION,
128
+ AccessBits,
129
+ EffectiveAccess,
130
+ EffectiveGrant,
131
+ ExplorerRenderer,
132
+ FetchedGroup,
133
+ FetchedObject,
134
+ FetchedRole,
135
+ FetchedShare,
136
+ FetchedUser,
137
+ FocusTypeScan,
138
+ ManageEdge,
139
+ MembershipEdge,
140
+ ObjectNode,
141
+ PrincipalKind,
142
+ ProvenanceKind,
143
+ ResolvedFocusType,
144
+ RoleGrantEdge,
145
+ RoleNode,
146
+ SchemaShareability,
147
+ ShareEdge,
148
+ SharingExplorerMeta,
149
+ SharingFocusType,
150
+ SharingGraph,
151
+ SharingView,
152
+ TypeInventory,
153
+ TypeSummary,
154
+ UserGroupNode,
155
+ UserNode,
156
+ build_sharing_graph,
157
+ build_sharing_view,
158
+ compute_effective_access,
159
+ evaluate_sharing,
160
+ resolve_focus_types,
161
+ )
162
+ from dhis2w_security.core.streaming import ReportWriter
163
+ from dhis2w_security.core.tokens import TokenAllowlists, TokensInventory, TokenView, evaluate_tokens
164
+ from dhis2w_security.core.transport import CORS_PROBE_ORIGIN, TransportHeaders, evaluate_transport
165
+ from dhis2w_security.core.versions import (
166
+ ADVISORY_PATCH_FLOOR,
167
+ ADVISORY_PATCH_FLOOR_REVIEWED,
168
+ MIN_SUPPORTED_LINE,
169
+ AdvisoryFloor,
170
+ ParsedVersion,
171
+ VersionPosture,
172
+ build_version_posture,
173
+ evaluate_version,
174
+ parse_dhis2_version,
175
+ )
176
+
177
+ __all__ = [
178
+ "ADVISORY_PATCH_FLOOR",
179
+ "ADVISORY_PATCH_FLOOR_REVIEWED",
180
+ "ANONYMOUS_PROBE_TARGETS",
181
+ "AUTHORITY_CATEGORIES",
182
+ "CANONICAL_CHECKS",
183
+ "CONTROL_CATALOG",
184
+ "CONNECT_PATHS",
185
+ "CORS_PROBE_ORIGIN",
186
+ "CREDENTIAL_PROBE_PATHS",
187
+ "DEFAULT_PROBE_PASSWORD",
188
+ "DEFAULT_PROBE_USERNAME",
189
+ "GET_ALLOWLIST",
190
+ "HIGH_RISK_ROLE_CATEGORIES",
191
+ "MAX_PROBE_ATTEMPTS",
192
+ "MIN_RECOMMENDED_PASSWORD_LENGTH",
193
+ "MIN_SUPPORTED_LINE",
194
+ "RELEASES_FEED_URL",
195
+ "REPORT_GUARDRAIL_NOTE",
196
+ "SEED_USERNAME",
197
+ "SEVERITY_ORDER",
198
+ "SHARING_CAVEATS",
199
+ "SHARING_FOCUS_TYPES",
200
+ "SHARING_SCHEMA_VERSION",
201
+ "AccessBits",
202
+ "AccountAuthorities",
203
+ "AdvisoryFloor",
204
+ "AnonymousResult",
205
+ "AuditFinding",
206
+ "AuditOptions",
207
+ "AuditPosture",
208
+ "AuditReport",
209
+ "AuditScopeMatrix",
210
+ "AuditSummary",
211
+ "AuthorityCategory",
212
+ "BoundCheck",
213
+ "CategoryMatch",
214
+ "CheckOutcome",
215
+ "CheckResult",
216
+ "CheckSpec",
217
+ "CheckStatus",
218
+ "ControlLog",
219
+ "ControlOutcome",
220
+ "ControlSpec",
221
+ "ControlStatus",
222
+ "ControlView",
223
+ "CorsWhitelist",
224
+ "CredentialProbeResult",
225
+ "CspDirectives",
226
+ "CsvRenderer",
227
+ "EffectiveAccess",
228
+ "EffectiveGrant",
229
+ "ExplorerRenderer",
230
+ "FetchedGroup",
231
+ "FetchedObject",
232
+ "FetchedRole",
233
+ "FetchedShare",
234
+ "FetchedUser",
235
+ "FocusTypeScan",
236
+ "GroupView",
237
+ "GuardrailViolation",
238
+ "GuestProbeTarget",
239
+ "HtmlRenderer",
240
+ "HubApp",
241
+ "InstalledApp",
242
+ "ItemView",
243
+ "LoginProviderView",
244
+ "ManageEdge",
245
+ "MarkdownRenderer",
246
+ "MembershipEdge",
247
+ "OAuth2ClientView",
248
+ "ObjectNode",
249
+ "ParsedVersion",
250
+ "PlainLogReporter",
251
+ "PrincipalKind",
252
+ "ProbeOutcome",
253
+ "ProgressReporter",
254
+ "ProvenanceKind",
255
+ "RedactedSecret",
256
+ "ReleaseFeed",
257
+ "ReleaseLine",
258
+ "ReportMeta",
259
+ "ReportRenderer",
260
+ "ReportView",
261
+ "ReportWriter",
262
+ "ResolvedFocusType",
263
+ "RichProgressReporter",
264
+ "RoleAudit",
265
+ "RoleGrantEdge",
266
+ "RoleNode",
267
+ "RouteTarget",
268
+ "RunManifest",
269
+ "SchemaShareability",
270
+ "Scorecard",
271
+ "SectionView",
272
+ "SettingsLike",
273
+ "Severity",
274
+ "ShareEdge",
275
+ "SharingExplorerMeta",
276
+ "SharingFocusType",
277
+ "SharingGraph",
278
+ "SharingView",
279
+ "StreamingRenderer",
280
+ "TextRenderer",
281
+ "TokenAllowlists",
282
+ "TokenView",
283
+ "TokensInventory",
284
+ "TransportHeaders",
285
+ "TwoFactorSource",
286
+ "TwoFactorSummary",
287
+ "TypeInventory",
288
+ "TypeSummary",
289
+ "UserGroupNode",
290
+ "UserHygiene",
291
+ "UserNode",
292
+ "VersionPosture",
293
+ "build_account_authorities",
294
+ "build_report_view",
295
+ "build_role_audit",
296
+ "build_sharing_graph",
297
+ "build_sharing_view",
298
+ "build_user_hygiene",
299
+ "build_version_posture",
300
+ "canonical_keys",
301
+ "categorise_authorities",
302
+ "classify_probe_status",
303
+ "compute_effective_access",
304
+ "evaluate_account_authorities",
305
+ "evaluate_apps",
306
+ "evaluate_audit_config",
307
+ "evaluate_auth_methods",
308
+ "evaluate_credential_probe",
309
+ "evaluate_guest",
310
+ "evaluate_hygiene",
311
+ "evaluate_roles",
312
+ "evaluate_routes",
313
+ "evaluate_settings",
314
+ "evaluate_sharing",
315
+ "evaluate_tokens",
316
+ "evaluate_transport",
317
+ "evaluate_two_factor_from_endpoint",
318
+ "evaluate_two_factor_from_user_field",
319
+ "evaluate_version",
320
+ "fetch_release_feed",
321
+ "finding_sort_key",
322
+ "grade_csp",
323
+ "guardrail_request_hook",
324
+ "label_for",
325
+ "make_reporter",
326
+ "parse_csp",
327
+ "parse_dhis2_version",
328
+ "parse_dhis_conf",
329
+ "probe_client",
330
+ "resolve_check_keys",
331
+ "resolve_focus_types",
332
+ "role_severity",
333
+ "run_audit",
334
+ "select_keys",
335
+ "severity_rank",
336
+ ]
@@ -0,0 +1,196 @@
1
+ """Installed-apps audit: side-loaded frontend code, App Hub update currency, and custom JS/CSS.
2
+
3
+ Three independent signals. Side-loaded apps (installed outside the App Hub, so carrying no
4
+ `app_hub_id`) run untrusted frontend code and are flagged without needing the hub. Update currency
5
+ compares each hub-backed app against the App Hub catalog and is skipped entirely when the hub is
6
+ unreachable, so an App Hub outage never reads as "every app is untrusted". Custom JavaScript or CSS
7
+ configured through the `keyCustomJs` / `keyCustomCss` system settings injects code into every
8
+ operator session and is persistent XSS in the hands of anyone who can write those settings.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from dhis2w_security.core.controls import CheckOutcome, ControlLog
16
+ from dhis2w_security.core.findings import AuditFinding, Severity
17
+
18
+ _CHECK = "apps"
19
+
20
+
21
+ class InstalledApp(BaseModel):
22
+ """One installed DHIS2 app with the fields the apps audit reasons over."""
23
+
24
+ model_config = ConfigDict(frozen=True)
25
+
26
+ name: str
27
+ version: str | None = None
28
+ app_hub_id: str | None = None
29
+ bundled: bool = False
30
+ core_app: bool = False
31
+
32
+
33
+ class HubApp(BaseModel):
34
+ """One App Hub catalog entry: its hub id and the version strings published for it."""
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ app_hub_id: str
39
+ versions: list[str] = []
40
+
41
+
42
+ def evaluate_apps(
43
+ *,
44
+ installed: list[InstalledApp],
45
+ hub: list[HubApp] | None,
46
+ custom_js: bool,
47
+ custom_css: bool,
48
+ ) -> CheckOutcome:
49
+ """Record the app controls (side-loaded, hub update, custom JS/CSS), flagging each that trips.
50
+
51
+ `hub=None` means the App Hub was unreachable, so update currency is skipped this run; the
52
+ side-loaded and custom-code signals do not need the hub.
53
+ """
54
+ log = ControlLog(_CHECK)
55
+ log.mark_passed("apps-side-loaded", "apps-custom-js", "apps-custom-css")
56
+ if hub is None:
57
+ log.mark_skipped("apps-update-available", "App Hub unreachable")
58
+ else:
59
+ log.mark_passed("apps-update-available")
60
+ for finding in _side_loaded_findings(installed):
61
+ log.record(finding)
62
+ if hub is not None:
63
+ for finding in _update_findings(installed, hub):
64
+ log.record(finding)
65
+ for finding in _custom_code_findings(custom_js=custom_js, custom_css=custom_css):
66
+ log.record(finding)
67
+ return log.result()
68
+
69
+
70
+ def _side_loaded_findings(installed: list[InstalledApp]) -> list[AuditFinding]:
71
+ """Flag each non-bundled app installed outside the App Hub as untrusted side-loaded code."""
72
+ findings: list[AuditFinding] = []
73
+ for app in installed:
74
+ if app.bundled or app.core_app or app.app_hub_id:
75
+ continue
76
+ findings.append(
77
+ AuditFinding(
78
+ check=_CHECK,
79
+ severity=Severity.HIGH,
80
+ title="Side-loaded app installed",
81
+ detail=(
82
+ f"App '{app.name}' was installed outside the App Hub (no app hub id); side-loaded "
83
+ "frontend code is untrusted and runs as every operator who opens it."
84
+ ),
85
+ subject=app.name,
86
+ group_key="side-loaded-app",
87
+ evidence={"app": app.name, "version": app.version or "unknown"},
88
+ control="apps-side-loaded",
89
+ )
90
+ )
91
+ return findings
92
+
93
+
94
+ def _update_findings(installed: list[InstalledApp], hub: list[HubApp]) -> list[AuditFinding]:
95
+ """Flag each hub-backed app whose installed version is behind the App Hub's latest."""
96
+ latest_by_id = {entry.app_hub_id: _latest(entry.versions) for entry in hub}
97
+ findings: list[AuditFinding] = []
98
+ for app in installed:
99
+ if not app.app_hub_id:
100
+ continue
101
+ latest = latest_by_id.get(app.app_hub_id)
102
+ if latest is None or not _is_newer(latest, app.version):
103
+ continue
104
+ findings.append(
105
+ AuditFinding(
106
+ check=_CHECK,
107
+ severity=Severity.MEDIUM,
108
+ title="App update available",
109
+ detail=(
110
+ f"App '{app.name}' is at {app.version or 'unknown'}; the App Hub has {latest}. "
111
+ "App updates often carry security fixes."
112
+ ),
113
+ subject=app.name,
114
+ group_key="app-update",
115
+ evidence={"app": app.name, "installed": app.version or "unknown", "latest": latest},
116
+ control="apps-update-available",
117
+ )
118
+ )
119
+ return findings
120
+
121
+
122
+ def _custom_code_findings(*, custom_js: bool, custom_css: bool) -> list[AuditFinding]:
123
+ """Flag configured custom JavaScript and CSS, each injected into every operator session."""
124
+ findings: list[AuditFinding] = []
125
+ if custom_js:
126
+ findings.append(
127
+ AuditFinding(
128
+ check=_CHECK,
129
+ severity=Severity.HIGH,
130
+ title="Custom JavaScript injected into every app",
131
+ detail=(
132
+ "keyCustomJs is set; the configured JavaScript runs in every operator's session across "
133
+ "all apps. Anyone who can write this setting holds persistent XSS over the instance."
134
+ ),
135
+ evidence={"setting": "keyCustomJs"},
136
+ control="apps-custom-js",
137
+ )
138
+ )
139
+ if custom_css:
140
+ findings.append(
141
+ AuditFinding(
142
+ check=_CHECK,
143
+ severity=Severity.HIGH,
144
+ title="Custom CSS injected into every app",
145
+ detail=(
146
+ "keyCustomCss is set; the configured CSS loads in every operator's session and can "
147
+ "deface the UI or exfiltrate data through crafted selectors."
148
+ ),
149
+ evidence={"setting": "keyCustomCss"},
150
+ control="apps-custom-css",
151
+ )
152
+ )
153
+ return findings
154
+
155
+
156
+ def _latest(versions: list[str]) -> str | None:
157
+ """Return the highest-versioned string, or None when the list is empty."""
158
+ candidates = [version for version in versions if version]
159
+ if not candidates:
160
+ return None
161
+ return max(candidates, key=_version_key)
162
+
163
+
164
+ def _is_newer(latest: str | None, current: str | None) -> bool:
165
+ """Return True when `latest` is a strictly higher version than `current`, ignoring trailing zeros."""
166
+ latest_key = _version_key(latest)
167
+ current_key = _version_key(current)
168
+ width = max(len(latest_key), len(current_key))
169
+ return _pad(latest_key, width) > _pad(current_key, width)
170
+
171
+
172
+ def _pad(parts: tuple[int, ...], width: int) -> tuple[int, ...]:
173
+ """Right-pad a version tuple with zeros so 1.2 and 1.2.0 compare equal."""
174
+ return parts + (0,) * (width - len(parts))
175
+
176
+
177
+ def _version_key(value: str | None) -> tuple[int, ...]:
178
+ """Parse a version into integer parts, ignoring a leading 'v' and any -prerelease / +build suffix.
179
+
180
+ '1.2.3' and 'v1.2.3-rc.1+build' both yield (1, 2, 3), so a hub pre-release like '1.2.4-SNAPSHOT'
181
+ still ranks above an installed '1.2.3'. A token with no leading digits contributes 0; a missing or
182
+ fully non-numeric version yields (0,). This is deliberately more tolerant than the strict-numeric
183
+ helper in the `apps` plugin, because the App Hub catalog carries free-form third-party versions.
184
+ """
185
+ if not value:
186
+ return (0,)
187
+ core = value.strip().lstrip("vV").split("-", 1)[0].split("+", 1)[0]
188
+ parts: list[int] = []
189
+ for token in core.split("."):
190
+ leading = ""
191
+ for char in token:
192
+ if not char.isdigit():
193
+ break
194
+ leading += char
195
+ parts.append(int(leading) if leading else 0)
196
+ return tuple(parts) or (0,)
@@ -0,0 +1,147 @@
1
+ """Verdicts over the DHIS2 auditing posture parsed from a `dhis.conf` copy.
2
+
3
+ The audit posture (the instance-wide enable flag, the file logger, the database sink, and the four
4
+ per-scope matrices) is not exposed over any DHIS2 API; it lives only in `dhis.conf`. The reducer therefore
5
+ has two modes driven by `AuditPosture.parsed`: when `parsed` is False (no `--dhis-conf` supplied) it emits a
6
+ single INFO stating the posture is not API-readable; never "auditing is off". When a `dhis.conf` copy was
7
+ parsed it reports each weak channel: the master switch off, both sinks off, and an explicitly-configured
8
+ matrix that is narrower than the DHIS2 default {CREATE, UPDATE, DELETE, SECURITY}.
9
+
10
+ The DHIS2 default (absent or empty matrix key) gives every scope {CREATE, UPDATE, DELETE, SECURITY}, so a
11
+ freshly-deployed instance with NO audit.* config is already audited on all change types. Only an EXPLICIT
12
+ matrix that omits one or more forensic types relative to the default is flagged as narrowly scoped. A scope
13
+ whose matrix is set to DISABLED (empty type set) is also flagged because it explicitly turns off a scope that
14
+ would otherwise be audited by default. See BUGS.md #54 for the upstream behaviour reference.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dhis2w_security.core.controls import CheckOutcome, ControlLog
20
+ from dhis2w_security.core.dhisconf import _FORENSIC_AUDIT_TYPES, AuditPosture
21
+ from dhis2w_security.core.findings import AuditFinding, Severity
22
+
23
+ _CHECK = "audit-config"
24
+
25
+
26
+ def evaluate_audit_config(posture: AuditPosture) -> CheckOutcome:
27
+ """Turn an audit posture into a CheckOutcome: the API-only INFO, or the parsed-posture weak-channel verdicts."""
28
+ log = ControlLog(_CHECK)
29
+ if not posture.parsed:
30
+ log.record(_api_only_finding())
31
+ log.mark_skipped("audit-config-system-disabled", "dhis.conf not provided")
32
+ log.mark_skipped("audit-config-both-sinks-off", "dhis.conf not provided")
33
+ log.mark_skipped("audit-config-narrow-scope", "dhis.conf not provided")
34
+ return log.result()
35
+ if posture.system_enabled is False:
36
+ log.record(_system_disabled_finding())
37
+ log.mark_skipped("audit-config-both-sinks-off", "auditing disabled instance-wide")
38
+ log.mark_skipped("audit-config-narrow-scope", "auditing disabled instance-wide")
39
+ return log.result()
40
+ log.mark_passed("audit-config-system-disabled")
41
+ if not posture.logger_enabled and not posture.database_enabled:
42
+ log.record(_both_sinks_off_finding())
43
+ else:
44
+ log.mark_passed("audit-config-both-sinks-off")
45
+ scope_findings = _scope_findings(posture)
46
+ for finding in scope_findings:
47
+ log.record(finding)
48
+ if not scope_findings:
49
+ log.mark_passed("audit-config-narrow-scope")
50
+ return log.result()
51
+
52
+
53
+ def _scope_findings(posture: AuditPosture) -> list[AuditFinding]:
54
+ """Scope-coverage findings: only explicit matrices that are narrower than the DHIS2 forensic default are flagged.
55
+
56
+ An absent or blank matrix key receives the DHIS2 default {CREATE, UPDATE, DELETE, SECURITY} and is
57
+ healthy. Only scopes where the operator supplied an EXPLICIT matrix (explicit=True) that omits one or
58
+ more of the forensic types are reported, including a DISABLED scope (empty type set).
59
+ """
60
+ narrow = _narrowly_scoped(posture)
61
+ if narrow:
62
+ return [_narrowly_scoped_finding(narrow=narrow)]
63
+ return []
64
+
65
+
66
+ def _narrowly_scoped(posture: AuditPosture) -> dict[str, tuple[str, ...]]:
67
+ """Map each EXPLICITLY-configured scope to the forensic audit types it omits vs. the DHIS2 default.
68
+
69
+ Scopes without an explicit matrix (explicit=False) receive the DHIS2 default and are not reported.
70
+ An explicitly-DISABLED scope omits all four forensic types.
71
+ """
72
+ narrow: dict[str, tuple[str, ...]] = {}
73
+ for scope in posture.scopes:
74
+ if not scope.explicit:
75
+ # No explicit matrix: DHIS2 applies its default, not a narrowing.
76
+ continue
77
+ missing = tuple(audit_type for audit_type in _FORENSIC_AUDIT_TYPES if audit_type not in scope.audit_types)
78
+ if missing:
79
+ narrow[scope.scope] = missing
80
+ return narrow
81
+
82
+
83
+ def _api_only_finding() -> AuditFinding:
84
+ """INFO: the audit posture is not API-readable; re-run with `--dhis-conf` on the server host."""
85
+ return AuditFinding(
86
+ check=_CHECK,
87
+ severity=Severity.INFO,
88
+ title="Audit configuration is not API-readable",
89
+ detail=(
90
+ "DHIS2 does not expose its audit configuration over the API; the audit.* keys live only in "
91
+ "dhis.conf. This check could not read the audit posture remotely; this is a property of DHIS2, "
92
+ "not a statement that auditing is off. Re-run with --dhis-conf <path> pointed at a copy of the "
93
+ "server's dhis.conf to evaluate the file logger, the database sink, and the per-scope matrices."
94
+ ),
95
+ group_key="audit-config-api-only",
96
+ evidence={"api_readable": "false"},
97
+ )
98
+
99
+
100
+ def _system_disabled_finding() -> AuditFinding:
101
+ """MEDIUM: system.audit.enabled is off, so no audit events are recorded regardless of the matrices."""
102
+ return AuditFinding(
103
+ check=_CHECK,
104
+ severity=Severity.MEDIUM,
105
+ title="Auditing is disabled instance-wide",
106
+ detail=(
107
+ "system.audit.enabled is off, so auditing is disabled instance-wide regardless of the per-scope "
108
+ "matrices. No CREATE/UPDATE/DELETE/READ/SECURITY events are recorded; there is no forensic trail."
109
+ ),
110
+ group_key="audit-system-disabled",
111
+ evidence={"system.audit.enabled": "off"},
112
+ control="audit-config-system-disabled",
113
+ )
114
+
115
+
116
+ def _both_sinks_off_finding() -> AuditFinding:
117
+ """MEDIUM: both the file logger and the database sink are off, so audit events have nowhere to land."""
118
+ return AuditFinding(
119
+ check=_CHECK,
120
+ severity=Severity.MEDIUM,
121
+ title="Both audit sinks are off",
122
+ detail=(
123
+ "audit.logger is off and audit.database is off, so audit events have nowhere to land. Enable at "
124
+ "least the file logger or the database sink."
125
+ ),
126
+ group_key="audit-logger-and-database-both-off",
127
+ evidence={"audit.logger": "off", "audit.database": "off"},
128
+ control="audit-config-both-sinks-off",
129
+ )
130
+
131
+
132
+ def _narrowly_scoped_finding(*, narrow: dict[str, tuple[str, ...]]) -> AuditFinding:
133
+ """MEDIUM: an explicitly-configured matrix is narrower than the DHIS2 forensic default."""
134
+ narrow_text = "; ".join(f"{scope} omits {', '.join(missing)}" for scope, missing in narrow.items())
135
+ return AuditFinding(
136
+ check=_CHECK,
137
+ severity=Severity.MEDIUM,
138
+ title="Audit scope coverage is narrower than the DHIS2 default",
139
+ detail=(
140
+ "DHIS2 applies {CREATE, UPDATE, DELETE, SECURITY} to every scope by default. One or more "
141
+ "explicitly-configured matrices narrow below that baseline, reducing the forensic trail. "
142
+ f"Scopes with explicit narrowing: {narrow_text}."
143
+ ),
144
+ group_key="audit-scope-narrowly-scoped",
145
+ evidence={"narrow_matrices": narrow_text},
146
+ control="audit-config-narrow-scope",
147
+ )