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