dhis2w-security 1.19.0__tar.gz

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-1.19.0/LICENSE +15 -0
  2. dhis2w_security-1.19.0/PKG-INFO +309 -0
  3. dhis2w_security-1.19.0/README.md +283 -0
  4. dhis2w_security-1.19.0/pyproject.toml +179 -0
  5. dhis2w_security-1.19.0/pyproject.toml.orig +146 -0
  6. dhis2w_security-1.19.0/src/dhis2w_security/__init__.py +1 -0
  7. dhis2w_security-1.19.0/src/dhis2w_security/core/__init__.py +336 -0
  8. dhis2w_security-1.19.0/src/dhis2w_security/core/apps.py +196 -0
  9. dhis2w_security-1.19.0/src/dhis2w_security/core/audit_config.py +147 -0
  10. dhis2w_security-1.19.0/src/dhis2w_security/core/auth_methods.py +208 -0
  11. dhis2w_security-1.19.0/src/dhis2w_security/core/authorities.py +296 -0
  12. dhis2w_security-1.19.0/src/dhis2w_security/core/controls.py +233 -0
  13. dhis2w_security-1.19.0/src/dhis2w_security/core/credentials.py +82 -0
  14. dhis2w_security-1.19.0/src/dhis2w_security/core/csp.py +149 -0
  15. dhis2w_security-1.19.0/src/dhis2w_security/core/dhisconf.py +213 -0
  16. dhis2w_security-1.19.0/src/dhis2w_security/core/findings.py +73 -0
  17. dhis2w_security-1.19.0/src/dhis2w_security/core/guardrails.py +212 -0
  18. dhis2w_security-1.19.0/src/dhis2w_security/core/guest.py +129 -0
  19. dhis2w_security-1.19.0/src/dhis2w_security/core/hygiene.py +410 -0
  20. dhis2w_security-1.19.0/src/dhis2w_security/core/models.py +26 -0
  21. dhis2w_security-1.19.0/src/dhis2w_security/core/net.py +174 -0
  22. dhis2w_security-1.19.0/src/dhis2w_security/core/options.py +23 -0
  23. dhis2w_security-1.19.0/src/dhis2w_security/core/orchestrator.py +73 -0
  24. dhis2w_security-1.19.0/src/dhis2w_security/core/registry.py +89 -0
  25. dhis2w_security-1.19.0/src/dhis2w_security/core/releases.py +81 -0
  26. dhis2w_security-1.19.0/src/dhis2w_security/core/report/__init__.py +1 -0
  27. dhis2w_security-1.19.0/src/dhis2w_security/core/report/assets/README.md +44 -0
  28. dhis2w_security-1.19.0/src/dhis2w_security/core/report/assets/dhis2-logo.png +0 -0
  29. dhis2w_security-1.19.0/src/dhis2w_security/core/report/assets/report.dc.html +565 -0
  30. dhis2w_security-1.19.0/src/dhis2w_security/core/report/assets/support.js +1525 -0
  31. dhis2w_security-1.19.0/src/dhis2w_security/core/report/base.py +61 -0
  32. dhis2w_security-1.19.0/src/dhis2w_security/core/report/csv.py +49 -0
  33. dhis2w_security-1.19.0/src/dhis2w_security/core/report/html.py +33 -0
  34. dhis2w_security-1.19.0/src/dhis2w_security/core/report/markdown.py +70 -0
  35. dhis2w_security-1.19.0/src/dhis2w_security/core/report/model.py +143 -0
  36. dhis2w_security-1.19.0/src/dhis2w_security/core/report/progress.py +50 -0
  37. dhis2w_security-1.19.0/src/dhis2w_security/core/report/text.py +44 -0
  38. dhis2w_security-1.19.0/src/dhis2w_security/core/report/view.py +185 -0
  39. dhis2w_security-1.19.0/src/dhis2w_security/core/roles.py +87 -0
  40. dhis2w_security-1.19.0/src/dhis2w_security/core/routes.py +201 -0
  41. dhis2w_security-1.19.0/src/dhis2w_security/core/settings_audit.py +224 -0
  42. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/__init__.py +99 -0
  43. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/assets/README.md +69 -0
  44. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/assets/d3.min.js +2 -0
  45. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/assets/dhis2-logo.png +0 -0
  46. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/assets/sharing-explorer.html +142 -0
  47. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/assets/sharing-runtime.js +836 -0
  48. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/builder.py +259 -0
  49. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/check.py +167 -0
  50. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/effective.py +160 -0
  51. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/explorer.py +36 -0
  52. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/focus.py +82 -0
  53. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/model.py +329 -0
  54. dhis2w_security-1.19.0/src/dhis2w_security/core/sharing/view.py +49 -0
  55. dhis2w_security-1.19.0/src/dhis2w_security/core/streaming.py +118 -0
  56. dhis2w_security-1.19.0/src/dhis2w_security/core/text.py +11 -0
  57. dhis2w_security-1.19.0/src/dhis2w_security/core/tokens.py +243 -0
  58. dhis2w_security-1.19.0/src/dhis2w_security/core/transport.py +437 -0
  59. dhis2w_security-1.19.0/src/dhis2w_security/core/versions.py +280 -0
  60. dhis2w_security-1.19.0/src/dhis2w_security/plugin.py +28 -0
  61. dhis2w_security-1.19.0/src/dhis2w_security/v41/__init__.py +1 -0
  62. dhis2w_security-1.19.0/src/dhis2w_security/v41/_wire.py +211 -0
  63. dhis2w_security-1.19.0/src/dhis2w_security/v41/audit.py +1310 -0
  64. dhis2w_security-1.19.0/src/dhis2w_security/v41/cli.py +287 -0
  65. dhis2w_security-1.19.0/src/dhis2w_security/v41/mcp.py +64 -0
  66. dhis2w_security-1.19.0/src/dhis2w_security/v41/models.py +35 -0
  67. dhis2w_security-1.19.0/src/dhis2w_security/v41/service.py +54 -0
  68. dhis2w_security-1.19.0/src/dhis2w_security/v42/__init__.py +1 -0
  69. dhis2w_security-1.19.0/src/dhis2w_security/v42/_wire.py +197 -0
  70. dhis2w_security-1.19.0/src/dhis2w_security/v42/audit.py +1310 -0
  71. dhis2w_security-1.19.0/src/dhis2w_security/v42/cli.py +285 -0
  72. dhis2w_security-1.19.0/src/dhis2w_security/v42/mcp.py +64 -0
  73. dhis2w_security-1.19.0/src/dhis2w_security/v42/models.py +35 -0
  74. dhis2w_security-1.19.0/src/dhis2w_security/v42/service.py +54 -0
  75. dhis2w_security-1.19.0/src/dhis2w_security/v43/__init__.py +1 -0
  76. dhis2w_security-1.19.0/src/dhis2w_security/v43/_wire.py +197 -0
  77. dhis2w_security-1.19.0/src/dhis2w_security/v43/audit.py +1310 -0
  78. dhis2w_security-1.19.0/src/dhis2w_security/v43/cli.py +285 -0
  79. dhis2w_security-1.19.0/src/dhis2w_security/v43/mcp.py +64 -0
  80. dhis2w_security-1.19.0/src/dhis2w_security/v43/models.py +35 -0
  81. dhis2w_security-1.19.0/src/dhis2w_security/v43/service.py +54 -0
@@ -0,0 +1,15 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
@@ -0,0 +1,309 @@
1
+ Metadata-Version: 2.4
2
+ Name: dhis2w-security
3
+ Version: 1.19.0
4
+ Summary: dhis2w plugin pack: the d2w security surface and the read-only security_* MCP tools.
5
+ Keywords: dhis2,security,audit,plugin,mcp
6
+ Author: Morten Hansen
7
+ Author-email: Morten Hansen <morten@winterop.com>
8
+ License-Expression: LicenseRef-Proprietary
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Dist: dhis2w-core>=1.19.0,<2.0
16
+ Requires-Dist: dhis2w-client>=1.19.0,<2.0
17
+ Requires-Dist: pydantic>=2.13
18
+ Requires-Dist: typer>=0.27.1
19
+ Requires-Dist: rich>=15
20
+ Requires-Dist: httpx2>=2.12.0
21
+ Requires-Dist: fastmcp>=3.4.7
22
+ Requires-Python: >=3.13
23
+ Project-URL: Homepage, https://github.com/winterop-com/dhis2w-security
24
+ Project-URL: Repository, https://github.com/winterop-com/dhis2w-security
25
+ Description-Content-Type: text/markdown
26
+
27
+ # dhis2w-security
28
+
29
+ The security plugin pack for [dhis2w](https://github.com/winterop-com/dhis2w). It adds
30
+ the `d2w security` command group to the `d2w` CLI and three read-only `security_*` tools
31
+ to the dhis2w MCP server, and it reaches a DHIS2 instance through the same profiles,
32
+ auth providers, and typed client every other dhis2w surface uses.
33
+
34
+ Everything the pack does is a read. Requests go out as GET against a tested allowlist,
35
+ with a single documented exception: the default-credential probe makes one HTTP Basic
36
+ login attempt (see [Default-credential probe](#default-credential-probe)).
37
+
38
+ ## Install
39
+
40
+ Alongside a global `d2w` install:
41
+
42
+ ```bash
43
+ uv tool install dhis2w-cli --with dhis2w-security
44
+ ```
45
+
46
+ Inside a project that already has `dhis2w-cli`:
47
+
48
+ ```bash
49
+ uv add dhis2w-security
50
+ ```
51
+
52
+ The pack advertises its plugin object under the `dhis2w.plugins.v1` entry-point group,
53
+ so `d2w` and the MCP server pick it up as soon as it is installed in the same
54
+ environment. Nothing needs to be registered by hand.
55
+
56
+ ## The CLI surface
57
+
58
+ ```
59
+ d2w security settings Password policy, credential expiry, registration, lockout
60
+ d2w security authorities The audited account's authorities, grouped by risk category
61
+ d2w security audit The full multi-check audit run, written to a report folder
62
+ d2w security report Re-render an existing run's reports from its JSONL spine
63
+ ```
64
+
65
+ `d2w --json security settings` emits the same data as a typed JSON object, as it does
66
+ everywhere in `d2w`.
67
+
68
+ `d2w security audit` streams a live progress display, runs the checks described below,
69
+ and writes a run folder holding the report in Markdown, plain text, CSV, and a
70
+ self-contained HTML bundle (open `report.dc.html`; the scan's data lives in
71
+ `report-data.js` beside a fixed template, runtime, and logo). Every HTML section header
72
+ carries a "See all checks" toggle listing each control the check evaluated with its live
73
+ PASS / FLAGGED / SKIPPED outcome, so a reader sees what was inspected and passed, not
74
+ only what tripped. A run is resumable, and `--checks` / `--skip` select the checks by
75
+ key. `d2w security report` re-renders a finished run without re-scanning.
76
+
77
+ `--sharing-graph` / `--visualize` additionally writes `sharing-explorer.html`, an offline
78
+ d3 bundle described under [Public metadata sharing](#public-metadata-sharing).
79
+
80
+ ## The MCP tools
81
+
82
+ Three tools, each one read-only GET against an already-allowlisted path:
83
+
84
+ | Tool | What it answers |
85
+ | --- | --- |
86
+ | `security_settings` | The security slice of `/api/systemSettings` |
87
+ | `security_authorities` | The audited account's authorities, categorised by risk |
88
+ | `security_version` | The instance's version and patch posture |
89
+
90
+ `security_version` deliberately skips the external release feed, so the tool stays a
91
+ single DHIS2 request with no external egress; the feed-based behind-latest-patch
92
+ refinement is audit-only. The audit runner, the credential probe, the guest probe, and
93
+ the sharing scan are CLI-only: they are slow, they write to disk, and the probe makes a
94
+ login attempt, none of which belongs behind an agent's tool call.
95
+
96
+ ## The checks
97
+
98
+ The audit runs these in canonical order. Each is selectable by its key.
99
+
100
+ ### Version and patch posture (`version`)
101
+
102
+ Reads the running version and grades it: an end-of-life version line, patch and hotfix
103
+ currency within the supported line, and a curated security-advisory patch floor sourced
104
+ from the DHIS2 GitHub advisories. A newer release line is an informational note, not a
105
+ finding, because a supported non-latest line is a healthy state.
106
+
107
+ ### Transport and security headers (`transport`)
108
+
109
+ Reads the TLS scheme off the resolved base URL and the security headers off one
110
+ `/api/system/info` response: Strict-Transport-Security, Content-Security-Policy (and
111
+ -Report-Only), X-Frame-Options, X-Content-Type-Options, the three cross-origin policy
112
+ headers, and Server. It flags plaintext HTTP, a missing HSTS / CSP / nosniff header,
113
+ missing anti-framing when neither X-Frame-Options nor a CSP `frame-ancestors` directive
114
+ is present, and a Server header that discloses a version token.
115
+
116
+ The grading goes past presence. A weak HSTS header has its `max-age` parsed with a
117
+ digit-only regex and raises one WARN when it is missing, invalid, non-positive, below a
118
+ day, or below the recommended year. A weak CSP is parsed into a directive map and
119
+ aggregated into one MEDIUM listing the failed directives: report-only-only mode, a
120
+ missing fetch directive on a content-governing policy, a broad source (`*`, `http:`,
121
+ `https:`, `data:`) or `unsafe-inline` / `unsafe-eval` in script sources, an un-locked-down
122
+ `object-src`, an unset or broad `base-uri`, and a present-but-broad `frame-ancestors`
123
+ (`strict-dynamic` is annotated, never warned). DHIS2's stock `frame-ancestors 'self';`
124
+ is a frame-only policy, so its content directives are left ungraded and a default
125
+ instance is never flagged; a missing `frame-ancestors` is owned by the anti-framing WARN
126
+ and never double-flagged. DHIS2 sets none of COOP/COEP/CORP, so those three absences are
127
+ aggregated into a single INFO rather than three WARNs that would fire on every stock
128
+ instance.
129
+
130
+ The same probe reads the live CORS response headers. DHIS2 emits
131
+ Access-Control-Allow-Origin and -Allow-Credentials only on a request carrying an Origin,
132
+ so the probe sends a benign foreign Origin (an unresolvable `.invalid` host, never the
133
+ instance's own origin, which DHIS2 always echoes) on its allowlisted
134
+ `GET /api/system/info` and grades what comes back: a wildcard or the reflected foreign
135
+ origin with credentials is HIGH, the same without credentials is WARN, and a specific
136
+ origin echoed with credentials is a trusted-origin-review WARN. That reads what the
137
+ server actually grants on the wire, complementing the settings check's read of the
138
+ declared `/api/configuration/corsWhitelist`.
139
+
140
+ ### System security settings (`settings`)
141
+
142
+ Password policy and registration settings: a weak minimum password length, failed-login
143
+ lockout disabled, passwords that never expire, self-registration captcha disabled, users
144
+ able to self-grant their own authorities, email verification not enforced, a permissive
145
+ `*` CORS origin from `/api/configuration/corsWhitelist` (plus an INFO enumerating a
146
+ non-empty no-wildcard allowlist for review), a static reminder that DHIS2 has no global
147
+ 2FA enforcement, and account recovery or email verification enabled while SMTP is
148
+ unconfigured. The email-verification WARN fires only when `enforceVerifiedEmail` is
149
+ explicitly off, so the key's absence on v41 is left untouched.
150
+
151
+ ### Audited account authorities (`authorities`)
152
+
153
+ Groups the audited account's dangerous authorities into named categories: superuser
154
+ (ALL), user and role management including the `F_IMPERSONATE_USER` account takeover, app
155
+ management and custom JS/CSS, SQL views, route management, system configuration, metadata
156
+ import and export, tracker administration, and data administration.
157
+
158
+ ### Instance role audit (`roles`)
159
+
160
+ Flags ALL-granting and dangerous-authority roles out of that same taxonomy, with member
161
+ counts. A role granting route management or user management is HIGH.
162
+
163
+ ### User account hygiene (`hygiene`)
164
+
165
+ Joins privileged accounts to login recency and 2FA posture, including
166
+ superuser-without-2FA via the v42+ `/api/users/twoFactor` audit endpoint. A privileged
167
+ account that never logged in is HIGH and one gone stale past `--stale-days` is MEDIUM,
168
+ both as per-user rows. Active non-privileged accounts that never logged in or went stale
169
+ roll up into at most two aggregate WARNs carrying the offender count and a username
170
+ sample capped at ten, so a large instance never emits a row per account and a privileged
171
+ never-logged-in account is never double-counted. Password age is graded the same way but
172
+ independently of privilege: every active account whose password is older than
173
+ `--max-password-age` (a year by default) or was never set rolls up into one aggregate
174
+ WARN.
175
+
176
+ ### Default-credential probe (`credential-probe`)
177
+
178
+ One HTTP Basic login attempt against `/api/me` with the well-known admin/district pair,
179
+ flagged CRITICAL when it succeeds. It is on by default and `--no-credential-probe` turns
180
+ it off. Exactly one attempt is made, and the probe touches no path other than `/api/me`.
181
+
182
+ ### Anonymous access (`guest`)
183
+
184
+ Unauthenticated reads of login-required endpoints, plus the self-registration and
185
+ account-recovery state an anonymous caller can observe.
186
+
187
+ ### Installed apps (`apps`)
188
+
189
+ Inventories side-loaded frontend code, App Hub update currency, and custom JS/CSS
190
+ injection, degrading cleanly when the App Hub is unreachable.
191
+
192
+ ### Public metadata sharing (`sharing`)
193
+
194
+ Finds public-write and externally-accessible objects across the data-bearing and
195
+ exposure-prone metadata types. Each object's sharing block is decoded and built into a
196
+ single access graph alongside the user, role, and group principals, paged and capped by
197
+ `--max-objects` with loud truncation.
198
+
199
+ `--sharing-graph` / `--visualize` writes `sharing-explorer.html` into the run folder: a
200
+ self-contained offline d3 bundle over that same graph, answering who can concretely read
201
+ or write an object and by what path. It carries an object tree, exposure triage,
202
+ by-principal and by-role pivots, a force-directed graph of the sharing topology, and an
203
+ access-matrix heatmap of group access per object type.
204
+
205
+ ### External login methods (`auth-methods`)
206
+
207
+ Inventories the pre-auth OIDC providers offered on the login page from `/api/loginConfig`
208
+ (each an INFO, as a federated trust path; SAML providers are not surfaced here) and the
209
+ OAuth2 clients DHIS2 acts as an authorization server for from `/api/oAuth2Clients`. A
210
+ broad grant type (client credentials, implicit, password, or the device-authorization
211
+ grant) or a loose redirect URI (a wildcard, or a non-loopback cleartext `http://` target;
212
+ loopback is not flagged, per RFC 8252) is MEDIUM, and a per-clean-client INFO is
213
+ suppressed when that client also trips a MEDIUM. `/api/oAuth2Clients` needs the
214
+ `F_OAUTH2_CLIENT_MANAGE` authority on v42/v43, so a 401/403/404 degrades the check with a
215
+ note (never retried) while the loginConfig findings still run. No secret field is
216
+ projected, so a client secret can never reach a finding.
217
+
218
+ ### Personal access tokens (`tokens`)
219
+
220
+ Inventories the tokens readable by the audited account from `/api/apiToken` and flags a
221
+ non-expiring token (a null, absent, or already-past `expire`, HIGH) and a token with no
222
+ IP allowlist (HIGH), calling out the worst case of one that both never expires and is
223
+ usable from anywhere. A MEDIUM inventory summary states the scope verbatim, because scope
224
+ is a runtime authority distinction: `ApiToken` is private by default, so a non-superuser
225
+ sees only its own tokens and the run adds an INFO caveat that other users' are invisible,
226
+ while an account with ALL gets the system-wide inventory. The token secret is never on
227
+ the wire and is never read.
228
+
229
+ ### Route API targets (`routes`)
230
+
231
+ Inventories DHIS2 Route objects from `/api/routes` and flags each whose destination
232
+ resolves to a private or internal address (RFC1918, loopback, link-local, unspecified,
233
+ IPv6 ULA), an internal hostname (`localhost`, `.internal`, `.local`, `.localdomain`), or
234
+ the cloud instance-metadata endpoint (the IPv4 and IPv6 metadata addresses and
235
+ `metadata.google.internal`, raised as the more specific metadata finding so one host
236
+ never produces two HIGHs). A Route is a server-side reverse proxy DHIS2 fetches on the
237
+ caller's behalf, which makes a private destination an SSRF primitive. The check also
238
+ flags `/**` subpath wildcards and routes with no required authorities that fall back to
239
+ ACL sharing, and notes routes carrying stored upstream credentials (the secret is
240
+ write-only upstream and never read; only the non-secret identity is shown). It inspects
241
+ the configured URL host only and never executes a route.
242
+
243
+ ### Audit logging configuration (`audit-config`)
244
+
245
+ Reports the DHIS2 audit configuration: the `system.audit.enabled` master switch, the
246
+ `audit.logger` file sink, the `audit.database` sink, and the four
247
+ `audit.metadata` / `audit.aggregate` / `audit.tracker` / `audit.api` scope matrices. That
248
+ posture lives only in `dhis.conf` and no API endpoint exposes it, so the API-first result
249
+ is an INFO saying the posture is not API-readable, never a claim that auditing is off.
250
+ Pass `--dhis-conf <path>` (or set `DHIS2_CONF_LOCATION`) pointed at a local copy of the
251
+ server's `dhis.conf` to evaluate it; the check then flags auditing disabled
252
+ instance-wide, both sinks off, every scope matrix blank while auditing is on, and narrow
253
+ scope coverage that leaves scopes unmonitored or omits CREATE/UPDATE/DELETE/SECURITY. A
254
+ missing or unreadable path degrades with a note.
255
+
256
+ Secret redaction is enforced by construction: the parser retains only the audit keys plus
257
+ a set / not-set flag for the confidential keys (the encryption, connection, analytics,
258
+ LDAP, Redis, Artemis, OAuth2-keystore, and monitoring passwords) and physically cannot
259
+ hold a secret value, so no password can reach a rendered report.
260
+
261
+ ## Version trees
262
+
263
+ The pack mirrors the host's three plugin trees. `dhis2w_security.v41`,
264
+ `dhis2w_security.v42`, and `dhis2w_security.v43` each carry the thin per-version shim
265
+ (`cli.py`, `mcp.py`, `service.py`, `models.py`, and the `_wire.py` holding that major's
266
+ wire divergences); v43 is the canonical baseline. Everything version-invariant — the
267
+ authority taxonomy, the severity model, the guardrail contract, the audit orchestration,
268
+ and the report and explorer rendering — lives once in `dhis2w_security.core`.
269
+
270
+ The plugin object binds the tree the host asks for and falls back to v43 for an
271
+ unrecognised version key.
272
+
273
+ ## Upstream DHIS2 quirks
274
+
275
+ The code cites these entries in the host repository's
276
+ [BUGS.md](https://github.com/winterop-com/dhis2w/blob/main/BUGS.md), which carry the
277
+ version observed, a `curl` repro, and the expected-versus-actual behaviour:
278
+
279
+ - **#14** — the Route `auth` block: v41's undiscriminated four-variant union has no
280
+ OAuth2 client-credentials variant, v42/v43's is a discriminated five-variant union.
281
+ - **#42** — `/api/systemSettings` returns `keyAnalysisDisplayProperty` lowercase, which
282
+ the generated `DisplayProperty` enum rejects; `SecuritySettings` is the projection that
283
+ parses.
284
+ - **#45** — an authorities endpoint that answers 500 on some deployments, which skips the
285
+ check rather than failing it.
286
+ - **#52** — the OAuth2 client wire shape: v41 reads `cid` with array-typed
287
+ `grantTypes` / `redirectUris`, v42/v43 read `clientId` with comma-string
288
+ `authorizationGrantTypes` / `redirectUris`.
289
+ - **#54** — the audit scope matrix that is explicitly DISABLED rather than absent, which
290
+ turns off a scope DHIS2 would otherwise audit by default.
291
+ - **#56** — `passwordLastUpdated` is nested under `userCredentials` on v41 and flattened
292
+ onto the User on v42/v43.
293
+ - **#57** — an authority naming divergence between the API and the auditor app's own
294
+ constant.
295
+ - **#58** — v42 and v43 removed every admin-readable per-user 2FA field from the User
296
+ resource, so enrolment is read from the superuser-only `/api/users/twoFactor` endpoint
297
+ and `/api/users/twoFactor` does not exist on v41 at all.
298
+ - **#60** — the CSP and HSTS headers a container or proxy adds rather than DHIS2 code,
299
+ and DHIS2's frame-only default policy.
300
+
301
+ ## Development
302
+
303
+ ```bash
304
+ make install # sync the environment
305
+ make lint # ruff + mypy + pyright
306
+ make test # pytest
307
+ ```
308
+
309
+ See `CLAUDE.md` for the conventions this repository follows.
@@ -0,0 +1,283 @@
1
+ # dhis2w-security
2
+
3
+ The security plugin pack for [dhis2w](https://github.com/winterop-com/dhis2w). It adds
4
+ the `d2w security` command group to the `d2w` CLI and three read-only `security_*` tools
5
+ to the dhis2w MCP server, and it reaches a DHIS2 instance through the same profiles,
6
+ auth providers, and typed client every other dhis2w surface uses.
7
+
8
+ Everything the pack does is a read. Requests go out as GET against a tested allowlist,
9
+ with a single documented exception: the default-credential probe makes one HTTP Basic
10
+ login attempt (see [Default-credential probe](#default-credential-probe)).
11
+
12
+ ## Install
13
+
14
+ Alongside a global `d2w` install:
15
+
16
+ ```bash
17
+ uv tool install dhis2w-cli --with dhis2w-security
18
+ ```
19
+
20
+ Inside a project that already has `dhis2w-cli`:
21
+
22
+ ```bash
23
+ uv add dhis2w-security
24
+ ```
25
+
26
+ The pack advertises its plugin object under the `dhis2w.plugins.v1` entry-point group,
27
+ so `d2w` and the MCP server pick it up as soon as it is installed in the same
28
+ environment. Nothing needs to be registered by hand.
29
+
30
+ ## The CLI surface
31
+
32
+ ```
33
+ d2w security settings Password policy, credential expiry, registration, lockout
34
+ d2w security authorities The audited account's authorities, grouped by risk category
35
+ d2w security audit The full multi-check audit run, written to a report folder
36
+ d2w security report Re-render an existing run's reports from its JSONL spine
37
+ ```
38
+
39
+ `d2w --json security settings` emits the same data as a typed JSON object, as it does
40
+ everywhere in `d2w`.
41
+
42
+ `d2w security audit` streams a live progress display, runs the checks described below,
43
+ and writes a run folder holding the report in Markdown, plain text, CSV, and a
44
+ self-contained HTML bundle (open `report.dc.html`; the scan's data lives in
45
+ `report-data.js` beside a fixed template, runtime, and logo). Every HTML section header
46
+ carries a "See all checks" toggle listing each control the check evaluated with its live
47
+ PASS / FLAGGED / SKIPPED outcome, so a reader sees what was inspected and passed, not
48
+ only what tripped. A run is resumable, and `--checks` / `--skip` select the checks by
49
+ key. `d2w security report` re-renders a finished run without re-scanning.
50
+
51
+ `--sharing-graph` / `--visualize` additionally writes `sharing-explorer.html`, an offline
52
+ d3 bundle described under [Public metadata sharing](#public-metadata-sharing).
53
+
54
+ ## The MCP tools
55
+
56
+ Three tools, each one read-only GET against an already-allowlisted path:
57
+
58
+ | Tool | What it answers |
59
+ | --- | --- |
60
+ | `security_settings` | The security slice of `/api/systemSettings` |
61
+ | `security_authorities` | The audited account's authorities, categorised by risk |
62
+ | `security_version` | The instance's version and patch posture |
63
+
64
+ `security_version` deliberately skips the external release feed, so the tool stays a
65
+ single DHIS2 request with no external egress; the feed-based behind-latest-patch
66
+ refinement is audit-only. The audit runner, the credential probe, the guest probe, and
67
+ the sharing scan are CLI-only: they are slow, they write to disk, and the probe makes a
68
+ login attempt, none of which belongs behind an agent's tool call.
69
+
70
+ ## The checks
71
+
72
+ The audit runs these in canonical order. Each is selectable by its key.
73
+
74
+ ### Version and patch posture (`version`)
75
+
76
+ Reads the running version and grades it: an end-of-life version line, patch and hotfix
77
+ currency within the supported line, and a curated security-advisory patch floor sourced
78
+ from the DHIS2 GitHub advisories. A newer release line is an informational note, not a
79
+ finding, because a supported non-latest line is a healthy state.
80
+
81
+ ### Transport and security headers (`transport`)
82
+
83
+ Reads the TLS scheme off the resolved base URL and the security headers off one
84
+ `/api/system/info` response: Strict-Transport-Security, Content-Security-Policy (and
85
+ -Report-Only), X-Frame-Options, X-Content-Type-Options, the three cross-origin policy
86
+ headers, and Server. It flags plaintext HTTP, a missing HSTS / CSP / nosniff header,
87
+ missing anti-framing when neither X-Frame-Options nor a CSP `frame-ancestors` directive
88
+ is present, and a Server header that discloses a version token.
89
+
90
+ The grading goes past presence. A weak HSTS header has its `max-age` parsed with a
91
+ digit-only regex and raises one WARN when it is missing, invalid, non-positive, below a
92
+ day, or below the recommended year. A weak CSP is parsed into a directive map and
93
+ aggregated into one MEDIUM listing the failed directives: report-only-only mode, a
94
+ missing fetch directive on a content-governing policy, a broad source (`*`, `http:`,
95
+ `https:`, `data:`) or `unsafe-inline` / `unsafe-eval` in script sources, an un-locked-down
96
+ `object-src`, an unset or broad `base-uri`, and a present-but-broad `frame-ancestors`
97
+ (`strict-dynamic` is annotated, never warned). DHIS2's stock `frame-ancestors 'self';`
98
+ is a frame-only policy, so its content directives are left ungraded and a default
99
+ instance is never flagged; a missing `frame-ancestors` is owned by the anti-framing WARN
100
+ and never double-flagged. DHIS2 sets none of COOP/COEP/CORP, so those three absences are
101
+ aggregated into a single INFO rather than three WARNs that would fire on every stock
102
+ instance.
103
+
104
+ The same probe reads the live CORS response headers. DHIS2 emits
105
+ Access-Control-Allow-Origin and -Allow-Credentials only on a request carrying an Origin,
106
+ so the probe sends a benign foreign Origin (an unresolvable `.invalid` host, never the
107
+ instance's own origin, which DHIS2 always echoes) on its allowlisted
108
+ `GET /api/system/info` and grades what comes back: a wildcard or the reflected foreign
109
+ origin with credentials is HIGH, the same without credentials is WARN, and a specific
110
+ origin echoed with credentials is a trusted-origin-review WARN. That reads what the
111
+ server actually grants on the wire, complementing the settings check's read of the
112
+ declared `/api/configuration/corsWhitelist`.
113
+
114
+ ### System security settings (`settings`)
115
+
116
+ Password policy and registration settings: a weak minimum password length, failed-login
117
+ lockout disabled, passwords that never expire, self-registration captcha disabled, users
118
+ able to self-grant their own authorities, email verification not enforced, a permissive
119
+ `*` CORS origin from `/api/configuration/corsWhitelist` (plus an INFO enumerating a
120
+ non-empty no-wildcard allowlist for review), a static reminder that DHIS2 has no global
121
+ 2FA enforcement, and account recovery or email verification enabled while SMTP is
122
+ unconfigured. The email-verification WARN fires only when `enforceVerifiedEmail` is
123
+ explicitly off, so the key's absence on v41 is left untouched.
124
+
125
+ ### Audited account authorities (`authorities`)
126
+
127
+ Groups the audited account's dangerous authorities into named categories: superuser
128
+ (ALL), user and role management including the `F_IMPERSONATE_USER` account takeover, app
129
+ management and custom JS/CSS, SQL views, route management, system configuration, metadata
130
+ import and export, tracker administration, and data administration.
131
+
132
+ ### Instance role audit (`roles`)
133
+
134
+ Flags ALL-granting and dangerous-authority roles out of that same taxonomy, with member
135
+ counts. A role granting route management or user management is HIGH.
136
+
137
+ ### User account hygiene (`hygiene`)
138
+
139
+ Joins privileged accounts to login recency and 2FA posture, including
140
+ superuser-without-2FA via the v42+ `/api/users/twoFactor` audit endpoint. A privileged
141
+ account that never logged in is HIGH and one gone stale past `--stale-days` is MEDIUM,
142
+ both as per-user rows. Active non-privileged accounts that never logged in or went stale
143
+ roll up into at most two aggregate WARNs carrying the offender count and a username
144
+ sample capped at ten, so a large instance never emits a row per account and a privileged
145
+ never-logged-in account is never double-counted. Password age is graded the same way but
146
+ independently of privilege: every active account whose password is older than
147
+ `--max-password-age` (a year by default) or was never set rolls up into one aggregate
148
+ WARN.
149
+
150
+ ### Default-credential probe (`credential-probe`)
151
+
152
+ One HTTP Basic login attempt against `/api/me` with the well-known admin/district pair,
153
+ flagged CRITICAL when it succeeds. It is on by default and `--no-credential-probe` turns
154
+ it off. Exactly one attempt is made, and the probe touches no path other than `/api/me`.
155
+
156
+ ### Anonymous access (`guest`)
157
+
158
+ Unauthenticated reads of login-required endpoints, plus the self-registration and
159
+ account-recovery state an anonymous caller can observe.
160
+
161
+ ### Installed apps (`apps`)
162
+
163
+ Inventories side-loaded frontend code, App Hub update currency, and custom JS/CSS
164
+ injection, degrading cleanly when the App Hub is unreachable.
165
+
166
+ ### Public metadata sharing (`sharing`)
167
+
168
+ Finds public-write and externally-accessible objects across the data-bearing and
169
+ exposure-prone metadata types. Each object's sharing block is decoded and built into a
170
+ single access graph alongside the user, role, and group principals, paged and capped by
171
+ `--max-objects` with loud truncation.
172
+
173
+ `--sharing-graph` / `--visualize` writes `sharing-explorer.html` into the run folder: a
174
+ self-contained offline d3 bundle over that same graph, answering who can concretely read
175
+ or write an object and by what path. It carries an object tree, exposure triage,
176
+ by-principal and by-role pivots, a force-directed graph of the sharing topology, and an
177
+ access-matrix heatmap of group access per object type.
178
+
179
+ ### External login methods (`auth-methods`)
180
+
181
+ Inventories the pre-auth OIDC providers offered on the login page from `/api/loginConfig`
182
+ (each an INFO, as a federated trust path; SAML providers are not surfaced here) and the
183
+ OAuth2 clients DHIS2 acts as an authorization server for from `/api/oAuth2Clients`. A
184
+ broad grant type (client credentials, implicit, password, or the device-authorization
185
+ grant) or a loose redirect URI (a wildcard, or a non-loopback cleartext `http://` target;
186
+ loopback is not flagged, per RFC 8252) is MEDIUM, and a per-clean-client INFO is
187
+ suppressed when that client also trips a MEDIUM. `/api/oAuth2Clients` needs the
188
+ `F_OAUTH2_CLIENT_MANAGE` authority on v42/v43, so a 401/403/404 degrades the check with a
189
+ note (never retried) while the loginConfig findings still run. No secret field is
190
+ projected, so a client secret can never reach a finding.
191
+
192
+ ### Personal access tokens (`tokens`)
193
+
194
+ Inventories the tokens readable by the audited account from `/api/apiToken` and flags a
195
+ non-expiring token (a null, absent, or already-past `expire`, HIGH) and a token with no
196
+ IP allowlist (HIGH), calling out the worst case of one that both never expires and is
197
+ usable from anywhere. A MEDIUM inventory summary states the scope verbatim, because scope
198
+ is a runtime authority distinction: `ApiToken` is private by default, so a non-superuser
199
+ sees only its own tokens and the run adds an INFO caveat that other users' are invisible,
200
+ while an account with ALL gets the system-wide inventory. The token secret is never on
201
+ the wire and is never read.
202
+
203
+ ### Route API targets (`routes`)
204
+
205
+ Inventories DHIS2 Route objects from `/api/routes` and flags each whose destination
206
+ resolves to a private or internal address (RFC1918, loopback, link-local, unspecified,
207
+ IPv6 ULA), an internal hostname (`localhost`, `.internal`, `.local`, `.localdomain`), or
208
+ the cloud instance-metadata endpoint (the IPv4 and IPv6 metadata addresses and
209
+ `metadata.google.internal`, raised as the more specific metadata finding so one host
210
+ never produces two HIGHs). A Route is a server-side reverse proxy DHIS2 fetches on the
211
+ caller's behalf, which makes a private destination an SSRF primitive. The check also
212
+ flags `/**` subpath wildcards and routes with no required authorities that fall back to
213
+ ACL sharing, and notes routes carrying stored upstream credentials (the secret is
214
+ write-only upstream and never read; only the non-secret identity is shown). It inspects
215
+ the configured URL host only and never executes a route.
216
+
217
+ ### Audit logging configuration (`audit-config`)
218
+
219
+ Reports the DHIS2 audit configuration: the `system.audit.enabled` master switch, the
220
+ `audit.logger` file sink, the `audit.database` sink, and the four
221
+ `audit.metadata` / `audit.aggregate` / `audit.tracker` / `audit.api` scope matrices. That
222
+ posture lives only in `dhis.conf` and no API endpoint exposes it, so the API-first result
223
+ is an INFO saying the posture is not API-readable, never a claim that auditing is off.
224
+ Pass `--dhis-conf <path>` (or set `DHIS2_CONF_LOCATION`) pointed at a local copy of the
225
+ server's `dhis.conf` to evaluate it; the check then flags auditing disabled
226
+ instance-wide, both sinks off, every scope matrix blank while auditing is on, and narrow
227
+ scope coverage that leaves scopes unmonitored or omits CREATE/UPDATE/DELETE/SECURITY. A
228
+ missing or unreadable path degrades with a note.
229
+
230
+ Secret redaction is enforced by construction: the parser retains only the audit keys plus
231
+ a set / not-set flag for the confidential keys (the encryption, connection, analytics,
232
+ LDAP, Redis, Artemis, OAuth2-keystore, and monitoring passwords) and physically cannot
233
+ hold a secret value, so no password can reach a rendered report.
234
+
235
+ ## Version trees
236
+
237
+ The pack mirrors the host's three plugin trees. `dhis2w_security.v41`,
238
+ `dhis2w_security.v42`, and `dhis2w_security.v43` each carry the thin per-version shim
239
+ (`cli.py`, `mcp.py`, `service.py`, `models.py`, and the `_wire.py` holding that major's
240
+ wire divergences); v43 is the canonical baseline. Everything version-invariant — the
241
+ authority taxonomy, the severity model, the guardrail contract, the audit orchestration,
242
+ and the report and explorer rendering — lives once in `dhis2w_security.core`.
243
+
244
+ The plugin object binds the tree the host asks for and falls back to v43 for an
245
+ unrecognised version key.
246
+
247
+ ## Upstream DHIS2 quirks
248
+
249
+ The code cites these entries in the host repository's
250
+ [BUGS.md](https://github.com/winterop-com/dhis2w/blob/main/BUGS.md), which carry the
251
+ version observed, a `curl` repro, and the expected-versus-actual behaviour:
252
+
253
+ - **#14** — the Route `auth` block: v41's undiscriminated four-variant union has no
254
+ OAuth2 client-credentials variant, v42/v43's is a discriminated five-variant union.
255
+ - **#42** — `/api/systemSettings` returns `keyAnalysisDisplayProperty` lowercase, which
256
+ the generated `DisplayProperty` enum rejects; `SecuritySettings` is the projection that
257
+ parses.
258
+ - **#45** — an authorities endpoint that answers 500 on some deployments, which skips the
259
+ check rather than failing it.
260
+ - **#52** — the OAuth2 client wire shape: v41 reads `cid` with array-typed
261
+ `grantTypes` / `redirectUris`, v42/v43 read `clientId` with comma-string
262
+ `authorizationGrantTypes` / `redirectUris`.
263
+ - **#54** — the audit scope matrix that is explicitly DISABLED rather than absent, which
264
+ turns off a scope DHIS2 would otherwise audit by default.
265
+ - **#56** — `passwordLastUpdated` is nested under `userCredentials` on v41 and flattened
266
+ onto the User on v42/v43.
267
+ - **#57** — an authority naming divergence between the API and the auditor app's own
268
+ constant.
269
+ - **#58** — v42 and v43 removed every admin-readable per-user 2FA field from the User
270
+ resource, so enrolment is read from the superuser-only `/api/users/twoFactor` endpoint
271
+ and `/api/users/twoFactor` does not exist on v41 at all.
272
+ - **#60** — the CSP and HSTS headers a container or proxy adds rather than DHIS2 code,
273
+ and DHIS2's frame-only default policy.
274
+
275
+ ## Development
276
+
277
+ ```bash
278
+ make install # sync the environment
279
+ make lint # ruff + mypy + pyright
280
+ make test # pytest
281
+ ```
282
+
283
+ See `CLAUDE.md` for the conventions this repository follows.