mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-19 21:19:28 +00:00
Ran ousast scan --mode deep on the 5 app-shaped + sound pool targets (the fair change-scoped eval set from the shape classification). Detection 3/5: graphiti (hunter, Cypher injection), mcp-remote (hunter, url-scheme injection), dcmcp (ENGINE overlay -- 'outbound.request reached from parameter' on readFileFromUrl; parameter-as-source fired because an entry point is present). 2/5 missed: malla (DOM/template XSS) and dms (Jinja SSTI), both in families families.toml documents as limited. This validates the strategic pivot: the near-zero catch was substantially a TARGET MISMATCH -- on app-shaped repos the engine itself detects (0 -> 3/5). But all hits stop at suspicion/ static_corroboration; release-grade BLOCK still needs proof-arm elevation + family coverage + the DSPy hint loop. Cost ~$0.007/repo. So bet #1 (fix eval target) is necessary and material but not sufficient. Next: recompute a floor on an app-shaped sound subset / real app corpus (VAmPI, NodeGoat, DjanGoat, vibe-py); measure proof-arm suspicion->proven lift. Co-Authored-By: Claude Opus 4.8 <[email protected]>
417 lines
17 KiB
JSON
417 lines
17 KiB
JSON
{
|
|
"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"
|
|
}
|
|
]
|
|
}
|