{ "findings": [ { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/authentication.py:67:outbound.request", "function_name": null, "line": 67, "path": "document_merge_service/api/authentication.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink outbound.request reached from parameter", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: outbound.request" }, { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/tests/data/__init__.py:14:path.open", "function_name": null, "line": 14, "path": "document_merge_service/api/tests/data/__init__.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink path.open reached from parameter", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: path.open" }, { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/views.py:39:html.unescaped", "function_name": null, "line": 39, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink html.unescaped reached from request", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: html.unescaped" }, { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/views.py:52:outbound.request", "function_name": null, "line": 52, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink outbound.request reached from request", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: outbound.request" }, { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/views.py:57:html.unescaped", "function_name": null, "line": 57, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink html.unescaped reached from request", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: html.unescaped" }, { "confidence": "medium", "evidence_level": "static_corroboration", "finding_id": "overlay-coverage:document_merge_service/api/views.py:79:html.unescaped", "function_name": null, "line": 79, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "uninventoried sink html.unescaped reached from parameter", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "overlay-originated" ], "title": "Overlay coverage: html.unescaped" }, { "confidence": "medium", "evidence_level": "suspicion", "finding_id": "model:untrusted_destination:document_merge_service/api/views.py:52:merge", "function_name": "merge", "line": 52, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "request.data -> requests.post(url, files = {\"file\":response.content}) in document_merge_service/api/views.py (line 52, 17 steps)", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "model_entailed", "severity": "medium", "status": "enabled", "tags": [ "family:untrusted_destination", "rung:model_entailed" ], "title": "untrusted_destination (model_entailed)" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/authentication.py:32", "function_name": null, "line": 32, "path": "document_merge_service/api/authentication.py", "proposed_snippet": null, "ranking_priority": 11.7422, "rationale": "self.groups = userinfo.get(settings.OIDC_GROUPS_CLAIM) or []. Groups drive access control in views.py:25 (queryset.filter(group__in=self.request.user.groups)) and serializers.py:25. The claim is trusted verbatim from the userinfo response with no validation of issuer/audience, so any party able to influence that response controls group membership.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:authorization" ], "title": "Authorization groups taken directly from OIDC userinfo claim" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/authentication.py:68", "function_name": null, "line": 68, "path": "document_merge_service/api/authentication.py", "proposed_snippet": null, "ranking_priority": 11.7422, "rationale": "requests.get(settings.OIDC_USERINFO_ENDPOINT, verify=settings.OIDC_VERIFY_SSL, ...). OIDC_VERIFY_SSL is env-controlled (settings.py:193, default True). If set false, the bearer-token identity lookup accepts any TLS cert, allowing a MITM to forge userinfo (sub/groups) and thus impersonate users and gain group-based access.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:transport-security" ], "title": "OIDC userinfo fetched with configurable SSL verification" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/authentication.py:88", "function_name": null, "line": 88, "path": "document_merge_service/api/authentication.py", "proposed_snippet": null, "ranking_priority": 11.7422, "rationale": "cache.get_or_set(f\"authentication.userinfo.{hashsum_token}\", userinfo_method, timeout=settings.OIDC_BEARER_TOKEN_REVALIDATION_TIME). OIDC_BEARER_TOKEN_REVALIDATION_TIME defaults to 0 (settings.py:197-198). A 0 timeout means the userinfo is not cached, forcing a network call per request; conversely a large value keeps revoked tokens valid. The cache key is a sha256 of the token, so cache contents are keyed by token hash.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:session-management" ], "title": "Bearer token cached by hash with configurable revalidation timeout" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/engines.py:52", "function_name": null, "line": 52, "path": "document_merge_service/api/engines.py", "proposed_snippet": null, "ranking_priority": 10.7693, "rationale": "return ENGINES[engine](template) indexes the ENGINES dict with the engine value. The engine comes from the template model/serializer (serializers.py:31-34, views.py:36). A KeyError on an unknown engine would surface as a 500 rather than a validation error; the value is constrained by model choices but the serializer path passes data.get(\"engine\") before full validation.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:input-validation" ], "title": "Engine dispatch via dict lookup on user-controlled engine value" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/migrations/0001_initial.py:22", "function_name": null, "line": 22, "path": "document_merge_service/api/migrations/0001_initial.py", "proposed_snippet": null, "ranking_priority": 9.4474, "rationale": "(\"template\", models.FileField(max_length=1024, upload_to=\"\")) stores uploaded files directly in the media root with no subdirectory. Combined with the download view (views.py:66-84) which serves template.template.read() with a Content-Disposition attachment, uploaded filenames are not namespaced, raising collision/overwrite concerns.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:file-handling" ], "title": "Template FileField with upload_to empty string" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/serializers.py:25", "function_name": null, "line": 25, "path": "document_merge_service/api/serializers.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "if group and group not in request.user.groups: raise ValidationError. This is the write-side counterpart to the read-side filter in views.py:25. It relies on request.user.groups being trustworthy, which traces back to the unvalidated OIDC claim in authentication.py:32.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:authorization" ], "title": "Group assignment validated against request user's groups" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/tests/test_authentication.py:25", "function_name": null, "line": 25, "path": "document_merge_service/api/tests/test_authentication.py", "proposed_snippet": null, "ranking_priority": 11.908, "rationale": "userinfo = {\"sub\": \"1\", settings.OIDC_GROUPS_CLAIM: [\"test\"]} and the test asserts user.group == \"test\". The test confirms the implementation trusts the userinfo claim for group membership; no test covers issuer/audience verification or rejection of a mismatched token, so the trust boundary is untested.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:test-coverage" ], "title": "Auth test asserts group from mocked userinfo without issuer validation" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/views.py:25", "function_name": null, "line": 25, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "queryset.filter(group__in=self.request.user.groups) only applies when settings.GROUP_ACCESS_ONLY is true (default False, settings.py:190). When disabled, all templates are listed regardless of group. When enabled, AnonymousUser.groups is [] so filtering yields nothing, but the security posture hinges entirely on this env flag.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:authorization" ], "title": "Group-based queryset filtering depends on user.groups being populated" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/api/views.py:47", "function_name": null, "line": 47, "path": "document_merge_service/api/views.py", "proposed_snippet": null, "ranking_priority": 0.0, "rationale": "requests.post(f\"{settings.UNOCONV_URL}/unoconv/{convert}\", files={\"file\": response.content}) sends the rendered document to an external conversion service. The convert value is validated against UNOCONV_ALLOWED_TYPES (serializers.py:52-58) and UNOCONV_URL must be configured, but the response content/status are passed straight back to the client (views.py:52-56), so the external service's output is trusted.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:ssrf" ], "title": "Merge output forwarded to external UNOCONV_URL service" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/conftest.py:24", "function_name": null, "line": 24, "path": "document_merge_service/conftest.py", "proposed_snippet": null, "ranking_priority": 10.1065, "rationale": "TestUser(AnonymousUser) sets is_authenticated True and admin_client uses client.force_authenticate(user=admin_user). This is test-only scaffolding, but it means the group/authorization paths exercised in test_template.py never go through BearerTokenAuthentication, leaving the real auth boundary under-tested.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:test-coverage" ], "title": "TestUser bypasses real authentication for admin client" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/settings.py:26", "function_name": null, "line": 26, "path": "document_merge_service/settings.py", "proposed_snippet": null, "ranking_priority": 15.1654, "rationale": "SECRET_KEY = env.str(\"SECRET_KEY\", default=default(\"uuuuuuuuuu\")). The default() helper returns default_prod when ENV == \"production\", and ENV defaults to \"production\". So if SECRET_KEY is unset in production, the app silently uses the weak literal \"uuuuuuuuuu\" instead of failing. This is a predictable signing key for Django sessions/CSRF.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:secret-management" ], "title": "Hardcoded fallback SECRET_KEY in production default" }, { "confidence": "low", "evidence_level": "suspicion", "finding_id": "tool-hunter:document_merge_service/settings.py:28", "function_name": null, "line": 28, "path": "document_merge_service/settings.py", "proposed_snippet": null, "ranking_priority": 15.1654, "rationale": "ALLOWED_HOSTS = env.list(\"ALLOWED_HOSTS\", default=default([\"*\"])) \u2014 with ENV defaulting to production, the production default is [\"*\"], disabling Django host-header validation. Combined with DEBUG defaulting to False in prod, this permits arbitrary Host headers.", "reachability_conditions": [], "reachability_evidence": [], "reachability_status": "unknown", "rung": "suspicion", "severity": "medium", "status": "enabled", "tags": [ "tool-hunter", "family:misconfiguration" ], "title": "ALLOWED_HOSTS defaults to wildcard in production" } ] }