Security
sanitize_for_lognow carries a CodeQL-recognized CR/LF barrier (#2465/#2466 —py/log-injection). The first log-hardening pass wrapped the gate-rejection paths insanitize_for_log, but itsisprintable()-loop (which maps line breaks to?) is not a modeled CodeQL barrier, so thesanitize_for_log(request.path)calls inapi/openapi.py+observability/views.pystayed 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 everysanitize_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 bytest_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) — theDJUST_TRUSTED_PROXY_COUNTmisconfiguration warning inpython/djust/_client_ip.pynow logs the offending value's TYPE (type(raw).__name__), not the rawsettingsvalue (CodeQL treatssettingsreads 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_gateandobservability/views.py:_gatenow wrap the user-controlledrequest.pathwith the existingdjust._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 acrossapi/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):msgpack1.1.2 → 1.2.1 (GHSA-6v7p-g79w-8964 — HIGH; out-of-bounds read / crash onUnpackerreuse after a caught error —msgpackis on djust's WS wire-protocol path),ujson5.12.1 → 5.13.0 (CVE-2026-54911 / GHSA-3j69-69wj-xqx2 — malformed/truncated UTF-8 silently rewritten indumps()),pydantic-settings2.14.0 → 2.14.2 (GHSA-4xgf-cpjx-pc3j —NestedSecretsSettingsSourcesymlink-follow outsidesecrets_dir). npm (package-lock.json, dev/test — transitive viajsdom):undici7.25.0 → 7.28.0, closing six advisories at once — CVE-2026-9697 / GHSA-vmh5-mc38-953g (HIGH, TLS cert-validation bypass via droppedrequestTlsin 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 (nopyproject.toml/package.jsonchange). Full pytest suite (incl. the wire-protocol / WebSocket paths exercisingmsgpack) + the 1743-case JS suite green on the bumped versions. - Made the
_ALWAYS_EXCLUDED_FIELDSserialization floor UNCONDITIONAL — a per-model allowlist can no longer re-exposepassword/is_superuser/is_staff(#1868 — CWE-200/CWE-359).serialization.py:_field_is_serializablechecked the per-modeldjust_serializable_fieldsallowlist BEFORE the_ALWAYS_EXCLUDED_FIELDSfloor, so a model declaringdjust_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-outdjust_serialize_sensitive_fields— a developer must explicitly take ownership of shipping a password hash / privilege flag (default is always deny). The single_field_is_serializablefunction gates all three auto-serialization callers (model fields,get_*methods,@propertyvalues), 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 viadjust_serializable_fieldswill no longer ship it; add the field todjust_serialize_sensitive_fields(a separate, explicit declaration) if that exposure was intentional. Regression: new cases inTestUnconditionalFloor(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 includingtest_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 shareddjust.auth.core.enforce_object_permissionchokepoint) 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_apianddispatch_server_functionmounted an object-scoped view and rancheck_view_auth/check_handler_permissionbut never the object-level check, so anexpose_apihandler /@server_functionran 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 callenforce_object_permission(view, request)after view-level auth +mount()(soget_object()'s access-determining state exists), before the handler runs / the render: the API paths return 403permission_deniedand 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 customget_object(behavior-preserving for non-object-scoped views) and fail-closed on denial / aNonerequest / any non-PermissionDeniedexception. Regression: new cases inpython/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 eachenforce_object_permissioncall turns the matching denies test red. The concern-4c structural pin inpython/djust/tests/test_mount_chokepoint_structural.py::TestMountOrchestrationChokepointis extended to assertenforce_object_permissionis referenced onsse.py(>=1) andapi/dispatch.py(>=2 call sites) so a future removal is caught. - WS
receive()routes theurl_changeverb through the singleViewRuntime.dispatch_messagechokepoint (#1852).LiveViewConsumer.receive()previously dispatchedurl_changestraight todispatch_url_change, BYPASSINGdispatch_message— the chokepoint the SSE transport already routes every inbound frame through. It now routesurl_changevia_dispatch_runtime_owned→ViewRuntime.dispatch_message(runtime.py) so a future security/policy control added at that chokepoint auto-applies to the WebSocket transport. AddsRUNTIME_OWNED_VERBS = frozenset({"url_change"})as the explicit pinned chokepoint set plus a documented WS-only extension set inreceive().mount(sticky/snapshot/actor — deferred to T1-A #1853) andevent(~16 WS-only behaviors the runtime path lacks) deliberately stay on their WS handlers. Behavior-preserving; wire output forurl_changeis identical. New cases inTestUrlChangeRoutedThroughChokepoint,TestUrlChangeEndToEndPreserved,TestWSOnlyFramesPreserved,TestRuntimeOwnedVerbsContract(python/djust/tests/test_ws_receive_runtime_dispatch_1852.py), gate-off-verified (#1468) via a realWebsocketCommunicatorspy ondispatch_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, andTestOriginParity(inpython/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_limittrip point (caller_key+handler_rate_check), and foreign-Origin/Host rejection (_is_allowed_origin/_host_in_allowed_hosts).TestMountOrchestrationChokepoint(inpython/djust/tests/test_mount_chokepoint_structural.py) adds an AST count-canary pinning thatwebsocket.py+runtime.pyeach 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 inLiveViewConsumer.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, signedstate_snapshotrestore, actor wiring, render) are untouched. Behavior-preserving: the helper returns exactly whatcheck_view_authreturns and propagatesPermissionDenied/_ensure_tenantexceptions, so each caller's existing envelope is unchanged; tenant resolve/bind is skipped on auth denial as before. Hardening side effect: a non-PermissionDeniederror 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 throughrun_pre_mount_authand the helper body still invokes its leaf chokepoints. New cases inTestRunPreMountAuthHelper,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) — neuteringrun_pre_mount_authto 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) andS011(inline-script / CSP). Both live inpython/djust/checks/security.pyand reuse the existing@register("djust")+_has_noqa+_is_liveview_subclassscaffold. S009 (Warning, AST) flags a LiveView that declares VIEW-level authorization (a truthylogin_required/permission_requiredclass attr, acheck_permissionsoverride, a Django/djustAccessMixin-family base, or an auth-gateddispatch) yet exposes a PUBLIC@event_handler/@actionmethod with NO per-handler gate (@permission_required) and no class-levelcheck_handler_permissionoverride — 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 realdj-root/dj-viewsubtree when no CSP is configured (no django-csp middleware, noCONTENT_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 skipssrc-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 sodata-src/data-type/data-noncearen'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 inTestS009EventHandlerNeedsAuthandTestS011InlineScriptCsp(inpython/tests/test_checks.py). Dogfooded (#1060) againstexamples/demo_projectwith 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. Newtests/playwright/test_browser_smoke.pydrives a dedicated/demos/browser-smoke/LiveView (BrowserSmokeView+ template, inexamples/demo_project/djust_demos/) with two assertions: (A) a mount canary —dj-click="bump"round-trips0 -> 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-blockingplaywright-testsleg (continue-on-error, NOT in thetest-summaryAND-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_FIELDSunion + per-modeldjust_exclude_fields/djust_serializable_fields), (2) HMAC signed snapshots (security/state_snapshot.pysign_snapshot/unsign_snapshot— TimestampSigner, slug+session binding, fail-closedNoneon tamper/expiry — plus the private-attr signing boundary:live_view.py_capture_snapshot_stateexcludes_*attrs from the signed blob and_restore_private_staterestores 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_gateDEBUG→opt-in-setting→authenticated→non-disclosing-404, siblingobservability/views.py:_gate, andauth/core.py:run_pre_mount_authas the mount-auth single-source), and (4)safe_setattr(security/attribute_guard.pydunder/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-runtest_transport_parity_security.py+test_mount_chokepoint_structural.pyagainst new transports). The one-line "Secure defaults" item indocs/PULL_REQUEST_CHECKLIST.mdis 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 fromdocs/README.md(new Security section) and CLAUDE.md Additional Documentation. Every citedfile:symbolwas grep-verified at write time (#1197) and the four patterns' runtime behaviors empirically confirmed (denylist floor,safe_setattrblock/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.ymlworkflow 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 syntheticsubprocess(shell=True)trigger (fails).
Fixed
- CodeQL code-quality cleanups (Note severity). Removed the unused module-global
logger(and its now-orphanimport logging) frompython/djust/checks/utils.py(py/unused-global-variable, CodeQL alert 2418), and converted theTransportProtocol's bare...stub bodies inpython/djust/runtime.pyto one-line docstrings — the protocol methods are now documented and CodeQLpy/ineffectual-statement(alerts 2463 / 2464) no longer flags the Ellipsis statements. No behavior change (the concreteWSConsumerTransport/SSESessionTransportimplementations are untouched). - Test-ordering pollution:
TestT016DjNavigateWithoutRoutesleakedROOT_URLCONF, breakingTestDemoRegistrationunder-n auto(#1862). The four T016 check tests inpython/tests/test_checks.pycombined@override_settings(ROOT_URLCONF=...)with the pytest-djangosettingsfixture 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_settingsvalue wins, soROOT_URLCONFstayed pinned at the routeless test URLconf (tests.api_test_urls_unmounted) for the rest of the xdist worker. Whentests/unit/test_demo_views.py::TestDemoRegistrationlanded in the same worker afterward, its fourresolve()tests (test_pwa_view_in_urlconf,test_tenant_view_in_urlconf,test_service_worker_url_resolves,test_manifest_url_resolves) raisedResolver404. Fix: the T016 tests now setROOT_URLCONFvia the singlesettings-fixture mechanism (settings.ROOT_URLCONF = ...) instead of@override_settings, so one restoration path handles every mutated setting. Defense-in-depth:TestDemoRegistration.setup_methodnow callsdjango.urls.clear_url_caches()so it never depends on a clean resolver cache. New regression cases inTestT016DoesNotLeakRootUrlconf(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 thedj-rootnow 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 assignscontainer.innerHTML = data.html. Per HTML spec, a<script>inserted by clone+insert (morph) or byinnerHTMLis parsed but NOT evaluated, so an inline page<script>inside thedj-root(e.g. one registering a delegateddocumentclick 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 singlewindow.djust._runInsertedScripts(container)helper (python/djust/static/djust/src/03-websocket.js) that re-creates each classic<script>viadocument.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 andapplication/json/importmapare left untouched. Idempotent via adata-djust-script-ranmarker 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 morphdescribe block (tests/js/mount-morph-script-exec-1848.test.js, 6 cases); repro-first builds thedj-rootviainnerHTMLwith inter-element whitespace + real<script>nodes (#1650 fidelity), drives the real helper from the built bundle, and asserts a delegateddocumentclick listener registers; gate-off-verified (#1468) — neutering the helper turns 4/6 cases red. Gzipped bundle delta: 172 bytes. url_change/dj-patchframes now stamp the consumer-owned wire version, ending the guaranteed VDOM version mismatch + forced reload (#1858, the #1788 parallel-path twin / #1646).url_changeframes (fromdj-patchclicks and browser popstate) are delegated toViewRuntime.dispatch_url_change, which stamped the wireversiondirectly fromrender_with_diff()'s return — the Rust render counter — andWSConsumerTransport.sendforwarded 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 firsturl_changeframe disagreed with the mount baseline, the client'sclientVdomVersion === data.version - 1check failed, and the client hit a non-recoverable error → forced page reload.dj-clickworked (it stamps_next_version());dj-patchdid not — that asymmetry was the fingerprint. #1788 had unified the wire version onto the consumer counter forhandle_mount/handle_eventbut not theViewRuntimedelegate paths. Fix: a newTransport.next_client_version(html, rust_version)hook is called by the runtime's render-send sites (dispatch_url_changeand_render_and_send);WSConsumerTransportreturnsconsumer._next_version_armed(html)— the same per-connection counterhandle_eventuses — which keeps the wire version monotonic with the mount baseline AND armsrequest_htmlrecovery to that version (#1788 / #1817), whileSSESessionTransportreturns 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 realWebsocketCommunicatorround-trips: 3 regression cases inpython/djust/tests/test_url_change_wire_version_1858.py(test_url_change_stamps_consumer_version_not_rust_counterasserts the dj-click/dj-patch asymmetry + the[1, 2, 3, 4]no-collision chain,test_version_monotonic_across_click_then_urlchange_then_clickpins the strict sequence across the url_change boundary,test_url_change_arms_recovery_to_its_own_versionpins #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 soS009/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,S009early-returned, andS011saw 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 againstexamples/demo_projectfrom 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 inTestGetProjectAppDirsDiscovery1865(python/tests/test_checks_app_dir_discovery_1865.py) exercise the real helper (mockingapps.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.