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.
- dhis2w_security/__init__.py +1 -0
- dhis2w_security/core/__init__.py +336 -0
- dhis2w_security/core/apps.py +196 -0
- dhis2w_security/core/audit_config.py +147 -0
- dhis2w_security/core/auth_methods.py +208 -0
- dhis2w_security/core/authorities.py +296 -0
- dhis2w_security/core/controls.py +233 -0
- dhis2w_security/core/credentials.py +82 -0
- dhis2w_security/core/csp.py +149 -0
- dhis2w_security/core/dhisconf.py +213 -0
- dhis2w_security/core/findings.py +73 -0
- dhis2w_security/core/guardrails.py +212 -0
- dhis2w_security/core/guest.py +129 -0
- dhis2w_security/core/hygiene.py +410 -0
- dhis2w_security/core/models.py +26 -0
- dhis2w_security/core/net.py +174 -0
- dhis2w_security/core/options.py +23 -0
- dhis2w_security/core/orchestrator.py +73 -0
- dhis2w_security/core/registry.py +89 -0
- dhis2w_security/core/releases.py +81 -0
- dhis2w_security/core/report/__init__.py +1 -0
- dhis2w_security/core/report/assets/README.md +44 -0
- dhis2w_security/core/report/assets/dhis2-logo.png +0 -0
- dhis2w_security/core/report/assets/report.dc.html +565 -0
- dhis2w_security/core/report/assets/support.js +1525 -0
- dhis2w_security/core/report/base.py +61 -0
- dhis2w_security/core/report/csv.py +49 -0
- dhis2w_security/core/report/html.py +33 -0
- dhis2w_security/core/report/markdown.py +70 -0
- dhis2w_security/core/report/model.py +143 -0
- dhis2w_security/core/report/progress.py +50 -0
- dhis2w_security/core/report/text.py +44 -0
- dhis2w_security/core/report/view.py +185 -0
- dhis2w_security/core/roles.py +87 -0
- dhis2w_security/core/routes.py +201 -0
- dhis2w_security/core/settings_audit.py +224 -0
- dhis2w_security/core/sharing/__init__.py +99 -0
- dhis2w_security/core/sharing/assets/README.md +69 -0
- dhis2w_security/core/sharing/assets/d3.min.js +2 -0
- dhis2w_security/core/sharing/assets/dhis2-logo.png +0 -0
- dhis2w_security/core/sharing/assets/sharing-explorer.html +142 -0
- dhis2w_security/core/sharing/assets/sharing-runtime.js +836 -0
- dhis2w_security/core/sharing/builder.py +259 -0
- dhis2w_security/core/sharing/check.py +167 -0
- dhis2w_security/core/sharing/effective.py +160 -0
- dhis2w_security/core/sharing/explorer.py +36 -0
- dhis2w_security/core/sharing/focus.py +82 -0
- dhis2w_security/core/sharing/model.py +329 -0
- dhis2w_security/core/sharing/view.py +49 -0
- dhis2w_security/core/streaming.py +118 -0
- dhis2w_security/core/text.py +11 -0
- dhis2w_security/core/tokens.py +243 -0
- dhis2w_security/core/transport.py +437 -0
- dhis2w_security/core/versions.py +280 -0
- dhis2w_security/plugin.py +28 -0
- dhis2w_security/v41/__init__.py +1 -0
- dhis2w_security/v41/_wire.py +211 -0
- dhis2w_security/v41/audit.py +1310 -0
- dhis2w_security/v41/cli.py +287 -0
- dhis2w_security/v41/mcp.py +64 -0
- dhis2w_security/v41/models.py +35 -0
- dhis2w_security/v41/service.py +54 -0
- dhis2w_security/v42/__init__.py +1 -0
- dhis2w_security/v42/_wire.py +197 -0
- dhis2w_security/v42/audit.py +1310 -0
- dhis2w_security/v42/cli.py +285 -0
- dhis2w_security/v42/mcp.py +64 -0
- dhis2w_security/v42/models.py +35 -0
- dhis2w_security/v42/service.py +54 -0
- dhis2w_security/v43/__init__.py +1 -0
- dhis2w_security/v43/_wire.py +197 -0
- dhis2w_security/v43/audit.py +1310 -0
- dhis2w_security/v43/cli.py +285 -0
- dhis2w_security/v43/mcp.py +64 -0
- dhis2w_security/v43/models.py +35 -0
- dhis2w_security/v43/service.py +54 -0
- dhis2w_security-1.19.0.dist-info/METADATA +309 -0
- dhis2w_security-1.19.0.dist-info/RECORD +81 -0
- dhis2w_security-1.19.0.dist-info/WHEEL +4 -0
- dhis2w_security-1.19.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|