From 339fd64c2d4546a0baf96ef178d94c1cd415c45d Mon Sep 17 00:00:00 2001 From: bjcorder Date: Mon, 13 Jul 2026 08:39:29 -0500 Subject: [PATCH] feat: expand Django permission detection --- CHANGELOG.md | 2 + crates/authmap-adapters/src/django.rs | 212 ++++++++++++++++++++++-- crates/authmap-analysis/src/lib.rs | 224 ++++++++++++++++++++++++++ crates/authmap-core/src/lib.rs | 5 + docs/DIAGNOSTICS.md | 1 + docs/PARSERS_AND_ADAPTERS.md | 7 +- 6 files changed, 434 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c78f77..3650bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- Added Django/DRF settings-default, action-override, method-decorator, + multiline-import, and local mixin-chain permission evidence. - Added Express prefix-less middleware propagation with registration-order semantics and `app.all` catch-all route inventory. - Added Knex query-builder, Prisma transaction-group, and repository-pattern diff --git a/crates/authmap-adapters/src/django.rs b/crates/authmap-adapters/src/django.rs index 4506687..2a0d4aa 100644 --- a/crates/authmap-adapters/src/django.rs +++ b/crates/authmap-adapters/src/django.rs @@ -41,6 +41,7 @@ impl FrameworkAdapter for DjangoAdapter { let Some(root) = parsed.root_node() else { continue; }; + index.collect_drf_settings_defaults(parsed); collect_symbols(parsed, root, &mut index); } @@ -160,6 +161,16 @@ struct ViewSetAction { url_path: Option, dynamic_url_path: bool, dynamic_methods: bool, + permission_classes: Option, + authentication_classes: Option, +} + +#[derive(Clone, Debug)] +struct DrfSettingsDefaults { + permission_classes: Option, + authentication_classes: Option, + dynamic: bool, + span: Span, } #[derive(Clone, Debug)] @@ -264,10 +275,40 @@ struct DjangoIndex { routers: BTreeMap<(String, String), RouterBinding>, registrations: Vec, model_views: Vec, + drf_settings_defaults: Option, diagnostics: Vec, } impl DjangoIndex { + fn collect_drf_settings_defaults(&mut self, parsed: &ParsedFile) { + let path = parsed.source.path.replace('\\', "/"); + if !path.ends_with("settings.py") && !path.contains("/settings/") { + return; + } + let Some(permission_classes) = + django_setting_value(&parsed.text, "DEFAULT_PERMISSION_CLASSES") + else { + return; + }; + let authentication_classes = + django_setting_value(&parsed.text, "DEFAULT_AUTHENTICATION_CLASSES"); + let dynamic = !looks_like_static_permission_value(&permission_classes) + || authentication_classes + .as_deref() + .is_some_and(|value| !looks_like_static_permission_value(value)); + self.drf_settings_defaults = Some(DrfSettingsDefaults { + permission_classes: Some(permission_classes), + authentication_classes, + dynamic, + span: Span { + file: parsed.source.path.clone(), + line: 1, + column: 1, + byte_range: None, + }, + }); + } + fn resolve_viewset_classes(&mut self) { let keys = self.classes.keys().cloned().collect::>(); let mut resolved = BTreeMap::new(); @@ -426,6 +467,21 @@ impl DjangoIndex { } let mut diagnostics = self.diagnostics; + if self + .drf_settings_defaults + .as_ref() + .is_some_and(|defaults| defaults.dynamic) + { + diagnostics.push(diagnostic( + diagnostic_codes::DJANGO_DYNAMIC_SETTINGS_DEFAULT, + self.drf_settings_defaults + .as_ref() + .expect("checked above") + .span + .clone(), + "DRF settings default is dynamic; authorization coverage is review-only", + )); + } diagnostics.extend(include_diagnostics); diagnostics.sort_by_key(diagnostic_sort_key); AdapterOutput { @@ -1191,6 +1247,7 @@ fn emit_router_routes( inherited_evidence.clone(), base_confidence, base_notes.clone(), + index.drf_settings_defaults.as_ref(), ); } for action in &viewset.actions { @@ -1230,6 +1287,7 @@ fn emit_router_routes( inherited_evidence.clone(), confidence, notes.clone(), + index.drf_settings_defaults.as_ref(), ); } } @@ -1288,6 +1346,7 @@ fn emit_drf_route( mut source_evidence: Vec, confidence: Confidence, notes: Vec, + defaults: Option<&DrfSettingsDefaults>, ) { source_evidence.push(source_evidence_item( "drf_router_register", @@ -1312,18 +1371,50 @@ fn emit_drf_route( )); } let mut extensions = authmap_core::ExtensionMap::new(); - extensions.insert( - "authmap.django".to_string(), - serde_json::json!({ - "route_pattern_kind": "drf_router", - "handler_kind": handler_kind, - "class_name": viewset.name, - "method_name": action_name, - "router_name": registration.router_name, - "basename": registration.basename, - "lookup_field": lookup_field(viewset), - }), - ); + let mut django_metadata = serde_json::json!({ + "route_pattern_kind": "drf_router", + "handler_kind": handler_kind, + "class_name": viewset.name, + "method_name": action_name, + "router_name": registration.router_name, + "basename": registration.basename, + "lookup_field": lookup_field(viewset), + }); + if let Some(metadata) = django_metadata.as_object_mut() { + if let Some(value) = action_permission_classes(viewset, action_name) { + metadata.insert( + "action_permission_classes".to_string(), + serde_json::json!(value), + ); + } + if let Some(value) = action_authentication_classes(viewset, action_name) { + metadata.insert( + "action_authentication_classes".to_string(), + serde_json::json!(value), + ); + } + if let Some(defaults) = defaults { + if let Some(value) = &defaults.permission_classes { + metadata.insert( + "default_permission_classes".to_string(), + serde_json::json!(value), + ); + } + if let Some(value) = &defaults.authentication_classes { + metadata.insert( + "default_authentication_classes".to_string(), + serde_json::json!(value), + ); + } + if defaults.dynamic { + metadata.insert( + "default_permissions_dynamic".to_string(), + serde_json::json!(true), + ); + } + } + } + extensions.insert("authmap.django".to_string(), django_metadata); push_route_unique( routes, seen, @@ -1605,11 +1696,73 @@ fn viewset_action( url_path, dynamic_url_path, dynamic_methods, + permission_classes: keyword_value_text(parsed, call, "permission_classes"), + authentication_classes: keyword_value_text(parsed, call, "authentication_classes"), }); } None } +fn action_permission_classes(viewset: &ClassInfo, action_name: &str) -> Option { + viewset + .actions + .iter() + .find(|action| action.name == action_name) + .and_then(|action| action.permission_classes.clone()) +} + +fn action_authentication_classes(viewset: &ClassInfo, action_name: &str) -> Option { + viewset + .actions + .iter() + .find(|action| action.name == action_name) + .and_then(|action| action.authentication_classes.clone()) +} + +fn keyword_value_text(parsed: &ParsedFile, call: Node<'_>, name: &str) -> Option { + let arguments = call.child_by_field_name("arguments")?; + let mut cursor = arguments.walk(); + arguments + .children(&mut cursor) + .filter(|child| child.kind() == "keyword_argument") + .find_map(|keyword| { + let keyword_name = keyword.child_by_field_name("name")?; + (parsed.text_for(keyword_name)? == name) + .then(|| keyword.child_by_field_name("value")) + .flatten() + .and_then(|value| parsed.text_for(value).map(str::to_string)) + }) +} + +fn django_setting_value(text: &str, key: &str) -> Option { + let start = text.find(key)?; + let after_key = &text[start + key.len()..]; + let colon = after_key.find(':')?; + let value = after_key[colon + 1..].trim_start(); + if let Some(open) = value + .chars() + .next() + .filter(|ch| matches!(ch, '[' | '(' | '{')) + { + let close = match open { + '[' => ']', + '(' => ')', + '{' => '}', + _ => unreachable!(), + }; + let end = value.find(close)?; + return Some(value[..=end].to_string()); + } + value.lines().next().map(str::trim).map(str::to_string) +} + +fn looks_like_static_permission_value(value: &str) -> bool { + value.contains('[') + || value.contains('(') + || value.contains("AllowAny") + || value.contains("IsAuthenticated") +} + fn build_module_index(parsed_files: &[ParsedFile]) -> BTreeMap { let mut index = BTreeMap::new(); for parsed in parsed_files { @@ -1637,7 +1790,7 @@ fn parse_imports( module_index: &BTreeMap, ) -> BTreeMap { let mut imports = BTreeMap::new(); - for line in parsed.text.lines() { + for line in normalized_python_import_lines(&parsed.text) { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("from ") { let Some((module, imported_names)) = rest.split_once(" import ") else { @@ -1646,7 +1799,10 @@ fn parse_imports( let module = module.trim(); let base_file = resolve_python_import_module(parsed, module, module_index); for imported in imported_names.split(',') { - let imported = imported.trim(); + let imported = imported + .trim() + .trim_matches(|ch| matches!(ch, '(' | ')')) + .trim(); if imported.is_empty() || imported == "*" { continue; } @@ -1696,6 +1852,34 @@ fn parse_imports( imports } +fn normalized_python_import_lines(text: &str) -> Vec { + let mut lines = Vec::new(); + let mut pending = String::new(); + for line in text.lines() { + let trimmed = line.trim(); + if pending.is_empty() { + if trimmed.starts_with("from ") + && trimmed.contains(" import (") + && !trimmed.contains(')') + { + pending.push_str(trimmed); + continue; + } + lines.push(trimmed.to_string()); + continue; + } + pending.push(' '); + pending.push_str(trimmed); + if trimmed.contains(')') { + lines.push(std::mem::take(&mut pending)); + } + } + if !pending.is_empty() { + lines.push(pending); + } + lines +} + fn resolve_relative_submodule( parsed: &ParsedFile, module: &str, diff --git a/crates/authmap-analysis/src/lib.rs b/crates/authmap-analysis/src/lib.rs index d49fef0..60aeab0 100644 --- a/crates/authmap-analysis/src/lib.rs +++ b/crates/authmap-analysis/src/lib.rs @@ -4810,10 +4810,135 @@ fn extract_django_route_evidence( class_name, )); } + if route.framework == Framework::DjangoRestFramework { + let metadata = route.extensions.get("authmap.django"); + let action_permissions = metadata + .and_then(|value| value.get("action_permission_classes")) + .and_then(serde_json::Value::as_str); + let action_authentication = metadata + .and_then(|value| value.get("action_authentication_classes")) + .and_then(serde_json::Value::as_str); + if action_permissions.is_some() || action_authentication.is_some() { + evidence.retain(|item| { + !matches!( + item.mechanism.as_str(), + "drf_permission_classes" | "django_authentication_classes" + ) + }); + if let Some(value) = action_permissions { + evidence.push(django_permission_value_evidence( + route, + value, + handler.span.clone(), + "drf_action_permission_classes", + Confidence::High, + )); + } + if let Some(value) = action_authentication { + evidence.push(django_authentication_value_evidence( + route, + value, + handler.span.clone(), + "drf_action_authentication_classes", + Confidence::High, + )); + } + } else if !evidence.iter().any(|item| { + matches!( + item.mechanism.as_str(), + "drf_permission_classes" | "django_authentication_classes" + ) + }) { + let default_permissions = metadata + .and_then(|value| value.get("default_permission_classes")) + .and_then(serde_json::Value::as_str); + let default_authentication = metadata + .and_then(|value| value.get("default_authentication_classes")) + .and_then(serde_json::Value::as_str); + let dynamic = metadata + .and_then(|value| value.get("default_permissions_dynamic")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if let Some(value) = default_permissions { + evidence.push(django_permission_value_evidence( + route, + value, + handler.span.clone(), + "drf_settings_default_permission_classes", + if dynamic { + Confidence::Low + } else { + Confidence::High + }, + )); + } + if let Some(value) = default_authentication { + evidence.push(django_authentication_value_evidence( + route, + value, + handler.span.clone(), + "drf_settings_default_authentication_classes", + if dynamic { + Confidence::Low + } else { + Confidence::High + }, + )); + } + } + } evidence.extend(extract_route_param_scoping_evidence(route)); evidence } +fn django_permission_value_evidence( + route: &authmap_core::Route, + value: &str, + span: Option, + mechanism: &str, + confidence: Confidence, +) -> Evidence { + let (evidence_type, _, symbol_name) = + classify_drf_permission_classes(&value.to_ascii_lowercase()); + Evidence { + id: String::new(), + route_id: Some(route.id.clone()), + evidence_type, + mechanism: mechanism.to_string(), + symbol: Some(SymbolRef { + name: symbol_name, + span: span.clone(), + }), + span, + confidence, + notes: vec![format!("DRF permission declaration: {value}")], + extensions: authmap_core::ExtensionMap::new(), + } +} + +fn django_authentication_value_evidence( + route: &authmap_core::Route, + value: &str, + span: Option, + mechanism: &str, + confidence: Confidence, +) -> Evidence { + Evidence { + id: String::new(), + route_id: Some(route.id.clone()), + evidence_type: EvidenceType::Authn, + mechanism: mechanism.to_string(), + symbol: Some(SymbolRef { + name: value.to_string(), + span: span.clone(), + }), + span, + confidence, + notes: vec![format!("DRF authentication declaration: {value}")], + extensions: authmap_core::ExtensionMap::new(), + } +} + fn extract_nextjs_route_evidence( route: &authmap_core::Route, parsed_by_path: &BTreeMap<&str, &ParsedFile>, @@ -5598,6 +5723,9 @@ fn extract_django_class_evidence( evidence.extend(extract_django_class_attribute_evidence( route, class, inherited, rules, )); + evidence.extend(extract_django_method_decorator_evidence( + route, class, rules, inherited, + )); for method_name in [ "initial", "dispatch", @@ -5628,6 +5756,47 @@ fn extract_django_class_evidence( evidence } +fn extract_django_method_decorator_evidence( + route: &authmap_core::Route, + class: &PythonClassDef<'_>, + rules: &EvidenceRules, + inherited: bool, +) -> Vec { + let Some(decorated) = class + .node + .parent() + .filter(|node| node.kind() == "decorated_definition") + else { + return Vec::new(); + }; + let mut cursor = decorated.walk(); + decorated + .children(&mut cursor) + .filter(|node| node.kind() == "decorator") + .filter_map(|decorator| { + let text = class.parsed.text_for(decorator)?; + let stripped = text.trim_start_matches('@').trim(); + let args = stripped.strip_prefix("method_decorator(")?; + let guard = args.split(',').next()?.trim(); + let guard = terminal_symbol_name(guard); + let mut evidence = django_decorator_evidence( + route, + &guard, + "", + class.parsed.span_for(decorator), + rules, + )?; + if inherited && evidence.confidence == Confidence::High { + evidence.confidence = Confidence::Medium; + } + evidence + .notes + .push("Django method_decorator applies to class dispatch".to_string()); + Some(evidence) + }) + .collect() +} + fn extract_django_base_evidence( route: &authmap_core::Route, class: &PythonClassDef<'_>, @@ -9873,6 +10042,61 @@ def update_account(account_id: str): })); } + #[test] + fn django_permission_defaults_action_overrides_decorators_mixins_and_multiline_imports() { + let temp = TestDir::new("django-permission-gaps"); + write_file( + &temp.path().join("settings.py"), + "REST_FRAMEWORK = {\n 'DEFAULT_PERMISSION_CLASSES': [IsAuthenticated],\n}\n", + ); + write_file( + &temp.path().join("views.py"), + "from django.contrib.auth.decorators import login_required\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\nfrom rest_framework.decorators import action\nfrom rest_framework.viewsets import ModelViewSet\n\nclass OverrideViewSet(ModelViewSet):\n def list(self, request):\n return []\n\n @action(detail=False, methods=['post'], permission_classes=[IsAdminUser])\n def rotate(self, request):\n return None\n\n@method_decorator(login_required, name='dispatch')\nclass DecoratedView(View):\n def get(self, request):\n return None\n\nclass ProjectMixin(LoginRequiredMixin):\n pass\n\nclass ChainedMixinView(ProjectMixin, View):\n def get(self, request):\n return None\n\ndef multiline_view(request):\n return None\n", + ); + write_file( + &temp.path().join("urls.py"), + "from django.urls import include, path\nfrom rest_framework.routers import SimpleRouter\nfrom .views import OverrideViewSet\nfrom .views import (\n DecoratedView,\n ChainedMixinView,\n multiline_view,\n)\n\nrouter = SimpleRouter()\nrouter.register('override', OverrideViewSet, basename='override')\nurlpatterns = [\n path('decorated/', DecoratedView.as_view()),\n path('chained/', ChainedMixinView.as_view()),\n path('multiline/', multiline_view),\n path('api/', include(router.urls)),\n]\n", + ); + let plan = ScanPlan::new(vec![temp.path().to_path_buf()], None, ScanConfig::default()); + + let document = run_scan(&plan).expect("scan should succeed"); + let default_route = route_by_path(&document, "/api/override"); + let action_route = route_by_path(&document, "/api/override/rotate"); + let decorated_route = route_by_path(&document, "/decorated/"); + let chained_route = route_by_path(&document, "/chained/"); + let multiline_route = route_by_path(&document, "/multiline/"); + + assert_eq!( + coverage_for_route(&document, &default_route.id).class, + CoverageClass::AuthnOnly + ); + assert_eq!( + coverage_for_route(&document, &action_route.id).class, + CoverageClass::AdminGuarded + ); + assert_eq!( + coverage_for_route(&document, &decorated_route.id).class, + CoverageClass::AuthnOnly + ); + assert_eq!( + coverage_for_route(&document, &chained_route.id).class, + CoverageClass::AuthnOnly + ); + assert_eq!( + multiline_route + .handler + .as_ref() + .map(|handler| handler.name.as_str()), + Some("multiline_view") + ); + assert!( + !document + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "django_unresolved_handler") + ); + } + #[test] fn token_heuristics_use_whole_identifier_tokens() { let rules = EvidenceRules::new(&ScanConfig::default()); diff --git a/crates/authmap-core/src/lib.rs b/crates/authmap-core/src/lib.rs index ca3759f..20ca44a 100644 --- a/crates/authmap-core/src/lib.rs +++ b/crates/authmap-core/src/lib.rs @@ -198,6 +198,7 @@ pub mod diagnostic_codes { pub const DJANGO_DYNAMIC_INCLUDE_HELPER: &str = "django_dynamic_include_helper"; pub const DJANGO_INCLUDE_DEPTH_EXCEEDED: &str = "django_include_depth_exceeded"; pub const DJANGO_DYNAMIC_URL_PATH: &str = "django_dynamic_url_path"; + pub const DJANGO_DYNAMIC_SETTINGS_DEFAULT: &str = "django_dynamic_settings_default"; pub const DJANGO_UNRESOLVED_HANDLER: &str = "django_unresolved_handler"; pub const DJANGO_UNRESOLVED_INCLUDE: &str = "django_unresolved_include"; pub const DJANGO_URLPATTERN_CONTEXT_UNCERTAIN: &str = "django_urlpattern_context_uncertain"; @@ -312,6 +313,10 @@ pub const FIRST_PARTY_DIAGNOSTIC_CODES: &[(&str, DiagnosticCategory)] = &[ diagnostic_codes::DJANGO_DYNAMIC_URL_PATH, DiagnosticCategory::Adapter, ), + ( + diagnostic_codes::DJANGO_DYNAMIC_SETTINGS_DEFAULT, + DiagnosticCategory::Adapter, + ), ( diagnostic_codes::DJANGO_UNRESOLVED_HANDLER, DiagnosticCategory::Adapter, diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 06b98fe..3153e4e 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -51,6 +51,7 @@ emitting them. | `django_dynamic_include_helper` | Django include target is a helper call that could not be expanded statically | | `django_include_depth_exceeded` | Django include chain exceeded the maximum static include depth | | `django_dynamic_url_path` | Django URL path is dynamic and could not be resolved | +| `django_dynamic_settings_default` | DRF settings default is dynamic and emitted as review-only context | | `django_unresolved_handler` | Django URL handler could not be resolved statically | | `django_unresolved_include` | Django include module could not be resolved statically | | `django_urlpattern_context_uncertain` | Django URL helper call was outside a recognized `urlpatterns` context | diff --git a/docs/PARSERS_AND_ADAPTERS.md b/docs/PARSERS_AND_ADAPTERS.md index 124758d..368b981 100644 --- a/docs/PARSERS_AND_ADAPTERS.md +++ b/docs/PARSERS_AND_ADAPTERS.md @@ -98,9 +98,10 @@ and `urlpatterns += [...]`, DRF routers and `@action`, CBV mixins and (`@login_required`, `@permission_required`, `@user_passes_test`, `@staff_member_required`, `@api_view` + `@permission_classes`). Django ORM mutations include `create/get_or_create/update_or_create/bulk_create/update/ -bulk_update/delete/save`. *Not yet:* multi-line parenthesized imports, -settings-level `DEFAULT_PERMISSION_CLASSES`, `@method_decorator` on CBVs, and -per-`@action` `permission_classes` overrides. +bulk_update/delete/save`. DRF settings defaults, per-`@action` permission +overrides, `@method_decorator` CBV guards, multi-line parenthesized imports, and +local mixin chains are resolved conservatively. *Not yet:* dynamic settings +defaults beyond review-only context. **Express** — `app`/`router` method calls, mounted routers with prefix composition, route-level and array middleware, route chaining, and