djust 1.0.8

StableSecurityReleased
Install
pip install djust==1.0.8

Security

  • sanitize_for_log now carries a CodeQL-recognized CR/LF barrier (#2465/#2466 — py/log-injection). The first log-hardening pass wrapped the gate-rejection paths in sanitize_for_log, but its isprintable()-loop (which maps line breaks to ?) is not a modeled CodeQL barrier, so the sanitize_for_log(request.path) calls in api/openapi.py + observability/views.py stayed flagged (the alerts re-numbered as the lines shifted). The helper's return value is now an explicit .replace("\r", "").replace("\n", "") — the form CodeQL recognizes as a log-injection sanitizer — which clears every sanitize_for_log(<remote source>) call site. It is a runtime no-op (line breaks are already mapped to ? upstream), so all existing behavior and tests are unchanged. Pinned by test_log_sanitizer_barrier_pin (python/djust/tests/test_log_sanitization.py) so a "looks redundant, remove it" refactor can't silently re-open the alert (#1859); gate-off verified (#1468).
  • Logging hardened against three CodeQL findings (no behavior change for legitimate input). (1) py/clear-text-logging-sensitive-data (HIGH, CodeQL alert 2421) — the DJUST_TRUSTED_PROXY_COUNT misconfiguration warning in python/djust/_client_ip.py now logs the offending value's TYPE (type(raw).__name__), not the raw settings value (CodeQL treats settings reads as a sensitive source); the type is the actionable diagnostic ("you set a str, expected int") without echoing a config value to the log stream. (2)+(3) py/log-injection (MEDIUM, CodeQL alerts 2422 + 2423)api/openapi.py:_openapi_gate and observability/views.py:_gate now wrap the user-controlled request.path with the existing djust._log_utils.sanitize_for_log (CR/LF/control-char strip + 200-char truncate) before logging the gated-request warning, so an attacker cannot forge log lines via a crafted path. Uses the same sanitizer already applied across api/dispatch.py (no parallel-path drift, #1646). Regression: python/djust/tests/test_codeql_log_hardening.py — 3 reproduce-first cases (bad proxy-count value; CRLF-injected path on each gate), gate-off verified (#1468; reverting each fix turns its test red).
  • Dependency security bumps — 9 Dependabot alerts resolved (lockfile-only, no code change). pip (uv.lock, runtime): msgpack 1.1.2 → 1.2.1 (GHSA-6v7p-g79w-8964 — HIGH; out-of-bounds read / crash on Unpacker reuse after a caught error — msgpack is on djust's WS wire-protocol path), ujson 5.12.1 → 5.13.0 (CVE-2026-54911 / GHSA-3j69-69wj-xqx2 — malformed/truncated UTF-8 silently rewritten in dumps()), pydantic-settings 2.14.0 → 2.14.2 (GHSA-4xgf-cpjx-pc3j — NestedSecretsSettingsSource symlink-follow outside secrets_dir). npm (package-lock.json, dev/test — transitive via jsdom): undici 7.25.0 → 7.28.0, closing six advisories at once — CVE-2026-9697 / GHSA-vmh5-mc38-953g (HIGH, TLS cert-validation bypass via dropped requestTls in SOCKS5), CVE-2026-6734 / GHSA-hm92-r4w5-c3mj (HIGH, cross-origin request routing via SOCKS5 pool reuse), CVE-2026-9678 / GHSA-pr7r-676h-xcf6 (cache whitespace bypass), CVE-2026-9679 / GHSA-p88m-4jfj-68fv (Set-Cookie header injection), CVE-2026-6733 / GHSA-35p6-xmwp-9g52 (keep-alive response queue poisoning), CVE-2026-11525 / GHSA-g8m3-5g58-fq7m (Set-Cookie SameSite downgrade). All patched versions are within existing dependency constraints (no pyproject.toml / package.json change). Full pytest suite (incl. the wire-protocol / WebSocket paths exercising msgpack) + the 1743-case JS suite green on the bumped versions.
  • Made the _ALWAYS_EXCLUDED_FIELDS serialization floor UNCONDITIONAL — a per-model allowlist can no longer re-expose password/is_superuser/is_staff (#1868 — CWE-200/CWE-359). serialization.py:_field_is_serializable checked the per-model djust_serializable_fields allowlist BEFORE the _ALWAYS_EXCLUDED_FIELDS floor, so a model declaring djust_serializable_fields=['password'] (or ['is_staff'], ['is_superuser']) re-exposed those hardcore-sensitive fields to the client — the exact secure-by-default question surfaced by the #1867 SECURE_DEFAULTS.md review. The precedence is now flipped: the floor wins first (a floor field is dropped even when an allowlist names it), and the allowlist may only NARROW the remaining, non-floor set. The ONLY way to re-include a floor field is the new deliberate, loudly-named per-model opt-out djust_serialize_sensitive_fields — a developer must explicitly take ownership of shipping a password hash / privilege flag (default is always deny). The single _field_is_serializable function gates all three auto-serialization callers (model fields, get_* methods, @property values), so the fix is uniform with no parallel-path drift (#1646). Default-behavior change (action required for some apps): a model that re-exposed a floor field via djust_serializable_fields will no longer ship it; add the field to djust_serialize_sensitive_fields (a separate, explicit declaration) if that exposure was intentional. Regression: new cases in TestUnconditionalFloor (python/djust/tests/test_serializer_field_exposure_f19.py) — allowlist-cannot-reexpose-password, allowlist-cannot-reexpose-privilege-flags, allowlist-still-narrows-non-floor-fields, explicit-opt-out-reincludes-only-named-field, opt-out-without-allowlist-lifts-floor; reproduce-first + gate-off verified (#1468) — restoring the allowlist-wins precedence fails 4/5 including test_allowlist_cannot_reexpose_password.
  • Enforced object-permission (ADR-017) on the HTTP-API + SSE-legacy mount paths (#1857 — CWE-862 / IDOR). The post-mount object-permission check (get_object + has_object_permission, via the shared djust.auth.core.enforce_object_permission chokepoint) was enforced on the WS mount, runtime/url_change, HTTP-GET, and {% live_render %} paths (#10/#11/#12) but had two remaining gaps: (a) api/dispatch.py:dispatch_api and dispatch_server_function mounted an object-scoped view and ran check_view_auth / check_handler_permission but never the object-level check, so an expose_api handler / @server_function ran against a denied object (IDOR on the HTTP-API transport); and (b) sse.py:_sse_mount_view (the legacy SSE mount) had zero object-perm calls, so it rendered a denied object's initial HTML (IDOR on the SSE transport). Both now call enforce_object_permission(view, request) after view-level auth + mount() (so get_object()'s access-determining state exists), before the handler runs / the render: the API paths return 403 permission_denied and the SSE path pushes an error frame + aborts the mount (return False). Object-level authorization is now uniform across every mount/render entry point. The check is a pure no-op for views without a custom get_object (behavior-preserving for non-object-scoped views) and fail-closed on denial / a None request / any non-PermissionDenied exception. Regression: new cases in python/djust/tests/test_object_perm_api_sse_paths.py (test_api_dispatch_denies_forbidden_object, test_server_function_denies_forbidden_object, test_sse_mount_denies_forbidden_object, plus permitted-object + non-object-scoped no-op cases per path); reproduce-first + gate-off verified (#1468) — neutering each enforce_object_permission call turns the matching denies test red. The concern-4c structural pin in python/djust/tests/test_mount_chokepoint_structural.py::TestMountOrchestrationChokepoint is extended to assert enforce_object_permission is referenced on sse.py (>=1) and api/dispatch.py (>=2 call sites) so a future removal is caught.
  • WS receive() routes the url_change verb through the single ViewRuntime.dispatch_message chokepoint (#1852). LiveViewConsumer.receive() previously dispatched url_change straight to dispatch_url_change, BYPASSING dispatch_message — the chokepoint the SSE transport already routes every inbound frame through. It now routes url_change via _dispatch_runtime_ownedViewRuntime.dispatch_message (runtime.py) so a future security/policy control added at that chokepoint auto-applies to the WebSocket transport. Adds RUNTIME_OWNED_VERBS = frozenset({"url_change"}) as the explicit pinned chokepoint set plus a documented WS-only extension set in receive(). mount (sticky/snapshot/actor — deferred to T1-A #1853) and event (~16 WS-only behaviors the runtime path lacks) deliberately stay on their WS handlers. Behavior-preserving; wire output for url_change is identical. New cases in TestUrlChangeRoutedThroughChokepoint, TestUrlChangeEndToEndPreserved, TestWSOnlyFramesPreserved, TestRuntimeOwnedVerbsContract (python/djust/tests/test_ws_receive_runtime_dispatch_1852.py), gate-off-verified (#1468) via a real WebsocketCommunicator spy on dispatch_message.
  • Anti-drift net: parity axes for auth/object-perm/rate-limit/origin + mount-orchestration structural pin (#1850, #1851). Extended the WU1 anti-drift test nets so a future regression that lets one transport's security control drift from the others (or a #1853 migration that silently re-grows a parallel mount orchestration) is caught mechanically. TestViewAuthParity, TestObjectPermissionParity, TestRateLimitParity, and TestOriginParity (in python/djust/tests/test_transport_parity_security.py) each assert an IDENTICAL security verdict across the ws/runtime/sse transports at the shared-helper level — view-level auth (check_view_auth), object-level permission (enforce_object_permission), per-handler @rate_limit trip point (caller_key + handler_rate_check), and foreign-Origin/Host rejection (_is_allowed_origin / _host_in_allowed_hosts). TestMountOrchestrationChokepoint (in python/djust/tests/test_mount_chokepoint_structural.py) adds an AST count-canary pinning that websocket.py + runtime.py each still reference those shared mount-orchestration security calls (a fourth drift class alongside the existing dynamic-import / setattr / RequestFactory chokepoint scans). Each new assertion is gate-off-verified (#1468). Test-only change — no API or behavior change.
  • Single-sourced the shared pre-mount auth sequence across the WebSocket, runtime, and SSE mount paths (#1853). The pre-mount security SEQUENCE — view-level auth (check_view_auth) then, on auth success, tenant resolve (_ensure_tenant) + tenant ContextVar bind — was hand-copied in LiveViewConsumer.handle_mount (WS), ViewRuntime.dispatch_mount (runtime), and _sse_mount_view (legacy SSE). It is now extracted into one helper, djust.auth.core.run_pre_mount_auth, that all three paths route through, so a future edit cannot reorder the steps or drop one on a single path (parallel-path drift, #1646). The helper owns ONLY the sequence; each transport keeps its own verdict→envelope mapping (WS close 4403 / runtime + SSE error/navigate frame), and all WS-only mount mechanics (sticky-child, signed state_snapshot restore, actor wiring, render) are untouched. Behavior-preserving: the helper returns exactly what check_view_auth returns and propagates PermissionDenied / _ensure_tenant exceptions, so each caller's existing envelope is unchanged; tenant resolve/bind is skipped on auth denial as before. Hardening side effect: a non-PermissionDenied error during the runtime/SSE auth call previously logged-and-PROCEEDED (a latent fail-open gap) and now aborts fail-closed with a mount-error envelope, matching the WS path which already aborted. The post-mount object-permission (check_object_permission), url-change object-permission (enforce_object_permission), and reconstructed-Host binding (validated_host_from_scope) were deliberately left in place (not part of the pre-mount sequence). The group-1 concern-4 structural pin is strengthened to assert all three transports route through run_pre_mount_auth and the helper body still invokes its leaf chokepoints. New cases in TestRunPreMountAuthHelper, TestWebSocketMountSequence, TestRuntimeMountSequence, TestSSEMountSequence, TestCrossPathVerdictParity (python/djust/tests/test_mount_security_sequence_1853.py) + TestMountOrchestrationChokepoint (python/djust/tests/test_mount_chokepoint_structural.py); gate-off-verified (#1468) — neutering run_pre_mount_auth to always-allow fails the auth-denied test on WS, runtime, and SSE simultaneously.

Added

  • Two new secure-default system checks (#1854) — S009 (event-handler-needs-auth) and S011 (inline-script / CSP). Both live in python/djust/checks/security.py and reuse the existing @register("djust") + _has_noqa + _is_liveview_subclass scaffold. S009 (Warning, AST) flags a LiveView that declares VIEW-level authorization (a truthy login_required / permission_required class attr, a check_permissions override, a Django/djust AccessMixin-family base, or an auth-gated dispatch) yet exposes a PUBLIC @event_handler / @action method with NO per-handler gate (@permission_required) and no class-level check_handler_permission override — a user past the mount gate could call an ungated sensitive handler. It is conservative: private (_) handlers and read-only-looking handlers (load_/get_/list_/…) are exempt, falsy auth attrs don't count, and # noqa: S009 / DJUST_CONFIG['suppress_checks'] silence it. S011 (Warning, template scan) flags an inline executable <script> INSIDE a real dj-root / dj-view subtree when no CSP is configured (no django-csp middleware, no CONTENT_SECURITY_POLICY / CSP_* / SECURE_CSP* setting) — targeting the #1848 class (morphdom does not re-execute inserted <script>, so inline page JS inside the dj-root silently never runs) plus the CSP gap. It is low-false-positive: it skips src-includes, nonce-bearing scripts, and data blocks (application/json, text/template, …), blanks <pre>/<code> example markup, balances the dj-root subtree so page scripts AFTER the root (e.g. a post-root {% block extra_scripts %}) are not flagged, and uses (?<![\w-]) attr anchors so data-src/data-type/data-nonce aren't mistaken for the real attributes (#1517 hardening). S010 (rate-limit-presence) was intentionally NOT shipped — the prevention plan marks it advisory/opt-in only (high false-positive risk). Empirical canary (#1459) + gate-off self-test (#1468): new cases in TestS009EventHandlerNeedsAuth and TestS011InlineScriptCsp (in python/tests/test_checks.py). Dogfooded (#1060) against examples/demo_project with zero false positives.
  • Browser-smoke canary for the #1848/#1849 runtime-break class (#1855, closes #1849). The pytest suite is structurally blind to runtime/wiring breaks — a refused WS mount and an inline-<script>-inside-dj-root that the mount morph never executes both shipped in 1.0.7 with the 8237-passing suite + an HTTP-200 smoke green. New tests/playwright/test_browser_smoke.py drives a dedicated /demos/browser-smoke/ LiveView (BrowserSmokeView + template, in examples/demo_project/djust_demos/) with two assertions: (A) a mount canarydj-click="bump" round-trips 0 -> 1, proving the LiveView actually mounts over the WebSocket (catches a mount refusal, #1849 class 1); (B) an inline-script canary — an inline <script> INSIDE the dj-root wires a delegated tab toggle that flips .active (the #1848 class 2). Because #1848 is an OPEN framework bug in 1.0.7, the exact known signature (the inline script never executed) is a tolerated KNOWN-XFAIL — the canary becomes a hard regression guard once #1848 lands, while a genuinely-new break (mount refusal, or inline script ran but toggle broke) hard-fails now. Ships in the already-non-blocking playwright-tests leg (continue-on-error, NOT in the test-summary AND-gate) per #1534 — it must go green on a runner before any promotion to a hard merge gate. Verified locally against the running demo: mount canary passes, #1848 reproduces live as the tolerated xfail, and a control proves the assertion is correct behavior (a delegated listener outside the dj-root catches the same clicks).
  • docs/SECURE_DEFAULTS.md — secure-by-default pattern catalog + PR-checklist subsection + audit cadence (#1856). Documents the four proven secure-by-default patterns so feature authors copy the canonical shape instead of re-deriving the controls: (1) denylist serialization (serialization.py _resolve_sensitive_fields() floor + DJUST_SENSITIVE_FIELDS union + per-model djust_exclude_fields/djust_serializable_fields), (2) HMAC signed snapshots (security/state_snapshot.py sign_snapshot/unsign_snapshot — TimestampSigner, slug+session binding, fail-closed None on tamper/expiry — plus the private-attr signing boundary: live_view.py _capture_snapshot_state excludes _* attrs from the signed blob and _restore_private_state restores them UNSIGNED from the server-side session, so don't store auth/ownership/PII in _* expecting integrity), (3) fail-closed precedence gate (api/openapi.py:_openapi_gate DEBUG→opt-in-setting→authenticated→non-disclosing-404, sibling observability/views.py:_gate, and auth/core.py:run_pre_mount_auth as the mount-auth single-source), and (4) safe_setattr (security/attribute_guard.py dunder/private/format guard for client-controlled keys). Adds a "make a NEW feature secure-by-default" section and a docs-only quarterly audit cadence (re-inventory transport chokepoints, re-run test_transport_parity_security.py + test_mount_chokepoint_structural.py against new transports). The one-line "Secure defaults" item in docs/PULL_REQUEST_CHECKLIST.md is expanded into a subsection cross-referencing the four patterns (mirroring the "Transport chokepoint (#1646)" format) with fail-closed + transport-parity prompts; both docs are indexed from docs/README.md (new Security section) and CLAUDE.md Additional Documentation. Every cited file:symbol was grep-verified at write time (#1197) and the four patterns' runtime behaviors empirically confirmed (denylist floor, safe_setattr block/allow matrix, snapshot sign/verify + tamper/cross-view rejection).

Changed

  • Pre-release security audit: Bandit now BLOCKS on new high-severity findings (#1855). The pre-release-security-audit.yml workflow ran Bandit with || true (purely advisory). A new "Bandit high-severity gate (blocking)" step fails the job on any high-severity finding outside the repo's reviewed skip list — using the SAME -s B703,B308,B324,B301,B102 + test-dir exclusions the pre-commit Bandit hook uses (single source of truth), so the reviewed baseline is 0 high-severity and only NEW high-sev blocks a release. The reviewed exceptions are the framework's documented ones (mark_safe in templates, MD5 for non-security cache keys, pickle/exec for JIT, a bidi-char fixture in an upload-safety test). Dependency-CVE scanners (safety / pip-audit / cargo-audit / npm audit) stay advisory. Empirically verified non-tautological: 0 high-sev with the reviewed skips (passes), 3 with skips removed (fails), 1 on a synthetic subprocess(shell=True) trigger (fails).

Fixed

  • CodeQL code-quality cleanups (Note severity). Removed the unused module-global logger (and its now-orphan import logging) from python/djust/checks/utils.py (py/unused-global-variable, CodeQL alert 2418), and converted the Transport Protocol's bare ... stub bodies in python/djust/runtime.py to one-line docstrings — the protocol methods are now documented and CodeQL py/ineffectual-statement (alerts 2463 / 2464) no longer flags the Ellipsis statements. No behavior change (the concrete WSConsumerTransport / SSESessionTransport implementations are untouched).
  • Test-ordering pollution: TestT016DjNavigateWithoutRoutes leaked ROOT_URLCONF, breaking TestDemoRegistration under -n auto (#1862). The four T016 check tests in python/tests/test_checks.py combined @override_settings(ROOT_URLCONF=...) with the pytest-django settings fixture parameter AND a fixture mutation (settings.TEMPLATES = ... inside _set_template_dir) in the same test. The two settings-restoration mechanisms race at teardown and the @override_settings value wins, so ROOT_URLCONF stayed pinned at the routeless test URLconf (tests.api_test_urls_unmounted) for the rest of the xdist worker. When tests/unit/test_demo_views.py::TestDemoRegistration landed in the same worker afterward, its four resolve() tests (test_pwa_view_in_urlconf, test_tenant_view_in_urlconf, test_service_worker_url_resolves, test_manifest_url_resolves) raised Resolver404. Fix: the T016 tests now set ROOT_URLCONF via the single settings-fixture mechanism (settings.ROOT_URLCONF = ...) instead of @override_settings, so one restoration path handles every mutated setting. Defense-in-depth: TestDemoRegistration.setup_method now calls django.urls.clear_url_caches() so it never depends on a clean resolver cache. New regression cases in TestT016DoesNotLeakRootUrlconf (gate-off-verified: re-introducing the @override_settings+fixture-mutation combo fails the leak assertion). Reproduced deterministically and verified with 3 consecutive clean full-suite runs under -n auto.
  • Inline classic <script> inside the dj-root now executes after the WS-mount morph (#1848 — 1.0.7 regression). On mount, djust HTTP-pre-renders then MORPHS the pre-rendered DOM against the WS-mount HTML (#1610: morphChildren); the non-prerendered branch assigns container.innerHTML = data.html. Per HTML spec, a <script> inserted by clone+insert (morph) or by innerHTML is parsed but NOT evaluated, so an inline page <script> inside the dj-root (e.g. one registering a delegated document click listener for tab switching / code-copy buttons) silently never ran — no console error. Regressed in 1.0.7 when #1610 began morphing the prerender DOM (worked on 1.0.5rc3). Fixed with a single window.djust._runInsertedScripts(container) helper (python/djust/static/djust/src/03-websocket.js) that re-creates each classic <script> via document.createElement('script') + copy attributes + textContent + replaceWith (the only DOM op that makes the browser run an already-in-tree inert script), called after both mount branches (parallel-path-drift cure, #1646). Classic-only: type="djust/hook" colocated definitions and application/json / importmap are left untouched. Idempotent via a data-djust-script-ran marker so a WS reconnect / re-mount on the same DOM does not double-execute. Emits no framework-generated inline script (CSP-safe). This closes the #1855 browser-smoke inline-script canary's tolerated known-xfail (it becomes a hard regression guard now that #1848 has landed). New cases in the #1848 — inline <script> inside dj-root executes after mount morph describe block (tests/js/mount-morph-script-exec-1848.test.js, 6 cases); repro-first builds the dj-root via innerHTML with inter-element whitespace + real <script> nodes (#1650 fidelity), drives the real helper from the built bundle, and asserts a delegated document click listener registers; gate-off-verified (#1468) — neutering the helper turns 4/6 cases red. Gzipped bundle delta: 172 bytes.
  • url_change / dj-patch frames now stamp the consumer-owned wire version, ending the guaranteed VDOM version mismatch + forced reload (#1858, the #1788 parallel-path twin / #1646). url_change frames (from dj-patch clicks and browser popstate) are delegated to ViewRuntime.dispatch_url_change, which stamped the wire version directly from render_with_diff()'s return — the Rust render counter — and WSConsumerTransport.send forwarded it verbatim. The Rust counter is several renders ahead of the consumer counter on a real session (the HTTP-GET SSR pre-render + the WS-mount hydration re-render each advance it, while _next_version() counts only frames the consumer SENT), so the first url_change frame disagreed with the mount baseline, the client's clientVdomVersion === data.version - 1 check failed, and the client hit a non-recoverable error → forced page reload. dj-click worked (it stamps _next_version()); dj-patch did not — that asymmetry was the fingerprint. #1788 had unified the wire version onto the consumer counter for handle_mount / handle_event but not the ViewRuntime delegate paths. Fix: a new Transport.next_client_version(html, rust_version) hook is called by the runtime's render-send sites (dispatch_url_change and _render_and_send); WSConsumerTransport returns consumer._next_version_armed(html) — the same per-connection counter handle_event uses — which keeps the wire version monotonic with the mount baseline AND arms request_html recovery to that version (#1788 / #1817), while SSESessionTransport returns the Rust version unchanged (SSE never adopted the consumer counter — it is single-counter end-to-end and has no cross-counter drift; #1646 audit conclusion). Reproduce-first + gate-off verified (#1468) via real WebsocketCommunicator round-trips: 3 regression cases in python/djust/tests/test_url_change_wire_version_1858.py (test_url_change_stamps_consumer_version_not_rust_counter asserts the dj-click/dj-patch asymmetry + the [1, 2, 3, 4] no-collision chain, test_version_monotonic_across_click_then_urlchange_then_click pins the strict sequence across the url_change boundary, test_url_change_arms_recovery_to_its_own_version pins #1817 recovery arming) — reverting the WS hook to the Rust counter reproduces the [1, 2, 3, 3] collision + stale recovery version.
  • _get_project_app_dirs() no longer excludes project apps that live under a /djust/-named path (#1865). The shared check-discovery helper (python/djust/checks/utils.py) filtered out any app path that ended with "djust" OR contained the substring "/djust/". The filter's intent was to skip djust's OWN package dir so S009/S011 (and every other dir-walking check) don't lint the framework's own templates — but it was far too broad: a downstream project (or the repo itself) checked from INSIDE the djust repo tree lives under a …/djust/… path, so EVERY project app was dropped → _get_project_app_dirs() returned 0 dirs, S009 early-returned, and S011 saw only a fraction of templates. This blinded check dogfooding from within the repo (surfaced in PR #1864 review). The exclusion is now tightened to skip ONLY djust's actual package directory (os.path.realpath(os.path.dirname(djust.__file__))) or a directory inside it via the new _is_within_djust_package() helper — the framework's own templates stay excluded (intent preserved) while a consumer app that merely lives under a /djust/-named path is discovered. Verified against examples/demo_project from inside the repo: pre-fix discovery returned 0 app dirs, post-fix returns all 9 project apps + 10 template dirs with djust's package still excluded. New cases in TestGetProjectAppDirsDiscovery1865 (python/tests/test_checks_app_dir_discovery_1865.py) exercise the real helper (mocking apps.get_app_configs), reproduce-first + gate-off verified (#1468) — restoring the old /djust/-substring filter turns the two discovery tests red while the three intent-preserved exclusion tests stay green.

All releases · Atom feed