use crate::auth_analysis::model::SinkClass; use crate::utils::config::Config; #[derive(Debug, Clone)] pub struct AuthAnalysisRules { pub enabled: bool, pub finding_prefix: String, pub admin_path_patterns: Vec, pub admin_guard_names: Vec, pub login_guard_names: Vec, pub authorization_check_names: Vec, pub mutation_indicator_names: Vec, pub read_indicator_names: Vec, pub token_lookup_names: Vec, pub token_expiry_fields: Vec, pub token_recipient_fields: Vec, pub non_sink_receiver_types: Vec, pub non_sink_receiver_name_prefixes: Vec, /// Built-in / framework receivers whose first-segment, when matched /// exactly (case-sensitive), classifies the call as inherently /// non-data-layer. Used for browser/DOM globals (`document`, /// `window`, `localStorage`, `console`, ...) and stdlib helpers /// (`Math`, `JSON`, `Date`) where method names like `getById` / /// `addEventListener` would otherwise prefix-match the configured /// `read_indicator_names` / `mutation_indicator_names`. pub non_sink_global_receivers: Vec, /// Method-name allowlist: when the LAST segment of a callee matches /// (case-sensitive exact), the call is classified as non-sink /// regardless of receiver. Used for DOM-API methods /// (`addEventListener`, `getElementById`, `appendChild`, ...) that /// are categorically client-side and never authorization-relevant. pub non_sink_method_names: Vec, /// Receiver-chain first-segment prefixes that classify a call as a /// realtime publish (pub/sub, websocket, event stream). pub realtime_receiver_prefixes: Vec, /// Receiver-chain prefixes that classify a call as an outbound /// network sink (HTTP client, RPC caller). pub outbound_network_receiver_prefixes: Vec, /// Receiver-chain prefixes that classify a call as a cross-tenant /// cache access. pub cache_receiver_prefixes: Vec, /// ACL tables that, when JOIN-ed in a SELECT and pinned via /// `WHERE .user_id = ?N`, make every returned row /// membership-gated. See `sql_semantics::classify_sql_query`. pub acl_tables: Vec, } impl AuthAnalysisRules { pub fn disabled() -> Self { Self { enabled: false, finding_prefix: "auth".into(), admin_path_patterns: Vec::new(), admin_guard_names: Vec::new(), login_guard_names: Vec::new(), authorization_check_names: Vec::new(), mutation_indicator_names: Vec::new(), read_indicator_names: Vec::new(), token_lookup_names: Vec::new(), token_expiry_fields: Vec::new(), token_recipient_fields: Vec::new(), non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } } /// Last path segment of a type name (e.g. `std::collections::HashMap` → `HashMap`). /// Accepts either `::` or `.` as the path separator. fn type_last_segment(ty: &str) -> &str { let trimmed = ty .trim() .trim_start_matches('&') .trim_start_matches("mut ") .trim(); let after_colons = trimmed.rsplit("::").next().unwrap_or(trimmed); after_colons.rsplit('.').next().unwrap_or(after_colons) } /// Does `ty` (last path segment, case-sensitive) match a /// non-sink receiver type? The angle-bracket generic suffix is /// stripped first: `HashMap` → `HashMap`. pub fn is_non_sink_receiver_type(&self, ty: &str) -> bool { let base = Self::type_last_segment(ty); let base = base.split('<').next().unwrap_or(base).trim(); self.non_sink_receiver_types .iter() .any(|allowed| allowed == base) } /// Does the callee of a constructor expression (e.g. `HashMap::new`, /// `SmallVec::from`, `Vec::with_capacity`) produce a non-sink /// receiver? Matches when the type prefix is in /// `non_sink_receiver_types` AND the method is a known /// constructor verb. /// /// The callee string may use either `::` or `.` as the path /// separator (nyx's `callee_name` normalizes both via /// `member_chain`). pub fn is_non_sink_constructor_callee(&self, callee: &str) -> bool { let normalized = callee.replace("::", "."); let Some((ty, method)) = normalized.rsplit_once('.') else { return false; }; if !self.is_non_sink_receiver_type(ty) { return false; } matches!( method, "new" | "with_capacity" | "with_capacity_and_hasher" | "with_hasher" | "from" | "from_iter" | "new_in" | "default" ) } /// Does the first segment of a callee receiver chain look like a /// non-sink local variable, based on configured name prefixes? /// Used as a fallback when the type/binding cannot be resolved. pub fn receiver_matches_non_sink_prefix(&self, first_segment: &str) -> bool { if first_segment.is_empty() { return false; } self.non_sink_receiver_name_prefixes .iter() .any(|prefix| !prefix.is_empty() && first_segment.starts_with(prefix.as_str())) } /// Should a call on `callee` be skipped for Read/Mutation /// classification because its receiver is a local non-sink /// collection? The `non_sink_vars` set lists variable names /// flagged during the unit walk (e.g. `let mut counts = HashMap::new()`). pub fn callee_has_non_sink_receiver( &self, callee: &str, non_sink_vars: &std::collections::HashSet, ) -> bool { let first = first_receiver_segment(callee); if first.is_empty() { return false; } if non_sink_vars.contains(first) { return true; } self.receiver_matches_non_sink_prefix(first) } /// Does the first receiver-chain segment match a configured /// non-sink global (case-sensitive exact)? Used to recognise /// browser/DOM globals (`document.getElementById` → /// first-segment `document`) and stdlib helpers /// (`Math.random`, `JSON.stringify`). pub fn callee_has_non_sink_global_receiver(&self, callee: &str) -> bool { let first = first_receiver_segment(callee); if first.is_empty() { return false; } self.non_sink_global_receivers .iter() .any(|name| name == first) } /// Does the LAST segment of the callee match a configured non-sink /// method name (case-sensitive exact)? Used to recognise DOM-API /// methods like `addEventListener` / `appendChild` regardless of /// receiver — `someElement.addEventListener` is just as /// categorically client-side as `document.addEventListener`. pub fn callee_has_non_sink_method(&self, callee: &str) -> bool { let last = callee.rsplit('.').next().unwrap_or(callee); let last = last.rsplit("::").next().unwrap_or(last); if last.is_empty() { return false; } self.non_sink_method_names.iter().any(|name| name == last) } /// Does the first segment of the callee's receiver chain match any /// configured prefix in `prefixes`? Comparison is case-insensitive /// on the first segment and uses starts-with on each prefix. fn receiver_matches_any_prefix(&self, first_segment: &str, prefixes: &[String]) -> bool { if first_segment.is_empty() { return false; } let lower = first_segment.to_ascii_lowercase(); prefixes.iter().any(|prefix| { !prefix.is_empty() && lower.starts_with(prefix.to_ascii_lowercase().as_str()) }) } /// Classify a call into a [`SinkClass`]. /// /// Dispatch order (first match wins): /// 1. `InMemoryLocal` — receiver is a known non-sink collection /// (tracked in `non_sink_vars` or matches a configured /// non-sink prefix). /// 2. `RealtimePublish` — receiver first-segment matches a /// configured realtime prefix (e.g. `realtime`, `pubsub`). /// 3. `OutboundNetwork` — receiver first-segment matches a /// configured outbound-network prefix (e.g. `http`, `reqwest`). /// 4. `CacheCrossTenant` — receiver first-segment matches a /// configured cache prefix (e.g. `cache`, `redis`). /// 5. `DbMutation` — callee name matches `mutation_indicator_names`. /// 6. `DbCrossTenantRead` — callee name matches `read_indicator_names`. /// /// Returns `None` when the callee matches none of the above — the /// call site is ignored by ownership-gap checks. pub fn classify_sink_class( &self, callee: &str, non_sink_vars: &std::collections::HashSet, ) -> Option { if self.callee_has_non_sink_receiver(callee, non_sink_vars) { return Some(SinkClass::InMemoryLocal); } // Browser/DOM globals (`document.getElementById`, `window.scrollTo`, // `Math.random`, `JSON.parse`) and DOM-API methods on any receiver // (`el.addEventListener`, `parent.appendChild`) are categorically // not data-layer auth-relevant operations. These shapes would // otherwise prefix-match read/mutation indicators (`get`, `add`, // `remove`) — `getElementById` canonicalises to `getelementbyid` // which `starts_with("get")` — and falsely classify as // `DbCrossTenantRead` / `DbMutation`. if self.callee_has_non_sink_global_receiver(callee) || self.callee_has_non_sink_method(callee) { return Some(SinkClass::InMemoryLocal); } let first = first_receiver_segment(callee); if self.receiver_matches_any_prefix(first, &self.realtime_receiver_prefixes) { return Some(SinkClass::RealtimePublish); } if self.receiver_matches_any_prefix(first, &self.outbound_network_receiver_prefixes) { return Some(SinkClass::OutboundNetwork); } if self.receiver_matches_any_prefix(first, &self.cache_receiver_prefixes) { return Some(SinkClass::CacheCrossTenant); } if self.is_mutation(callee) { return Some(SinkClass::DbMutation); } if self.is_read(callee) { return Some(SinkClass::DbCrossTenantRead); } None } pub fn requires_admin_path(&self, path: &str) -> bool { let lower = path.to_ascii_lowercase(); let normalized = if lower.starts_with('/') { lower.clone() } else { format!("/{lower}") }; self.admin_path_patterns .iter() .map(|p| p.to_ascii_lowercase()) .any(|p| normalized.contains(&p) || lower.contains(p.trim_matches('/'))) } pub fn is_admin_guard(&self, name: &str, args: &[String]) -> bool { if matches_name(name, "PreAuthorize") || matches_name(name, "Secured") || matches_name(name, "RolesAllowed") || matches_name(name, "hasRole") || matches_name(name, "hasAuthority") { return args.iter().any(|arg| { let lower = strip_quotes(arg).to_ascii_lowercase(); lower.contains("admin") || lower.contains("role_admin") || lower.contains("manage") || lower.contains("superuser") }); } if self .admin_guard_names .iter() .any(|pattern| matches_name(name, pattern)) { return true; } if matches_name(name, "requireRole") && args .first() .is_some_and(|arg| strip_quotes(arg).eq_ignore_ascii_case("admin")) { return true; } if matches_name(name, "permission_required") || matches_name(name, "PermissionRequiredMixin") || matches_name(name, "user_passes_test") { return args.iter().any(|arg| { let lower = strip_quotes(arg).to_ascii_lowercase(); lower.contains("admin") || lower.contains("staff") || lower.contains("manage") || lower.contains("auth.") || lower.contains("change_") || lower.contains("delete_") || lower.contains("add_") }); } false } pub fn is_login_guard(&self, name: &str) -> bool { if matches_name(name, "isAuthenticated") || matches_name(name, "authenticated") || matches_name(name, "hasRole") || matches_name(name, "hasAuthority") || matches_name(name, "Secured") || matches_name(name, "RolesAllowed") || matches_name(name, "PreAuthorize") { return true; } self.login_guard_names .iter() .any(|pattern| matches_name(name, pattern)) } pub fn is_authorization_check(&self, name: &str) -> bool { if self .authorization_check_names .iter() .any(|pattern| matches_name(name, pattern)) { return true; } // Structural recogniser for the canonical Rust / cross-language // `require__` shape (`require_trip_member`, // `require_doc_owner`, `require_project_admin`). The resource // segment is project-specific so cannot be enumerated in the // per-language defaults; the `` suffix is a closed set of // authorization vocabulary. This recogniser closes a real-repo // FP cluster where a project-named membership helper was // shadowing every realtime/db sink in the file. is_require_resource_role_call(name) } pub fn is_token_lookup(&self, name: &str) -> bool { self.token_lookup_names .iter() .any(|pattern| matches_name(name, pattern)) } pub fn is_token_lookup_call(&self, name: &str, call_text: &str) -> bool { if self.is_token_lookup(name) { return true; } let lower = call_text.to_ascii_lowercase(); let looks_like_token_query = lower.contains("token=") || lower.contains("token =") || lower.contains("invite") || lower.contains("invitation") || lower.contains("accept_key"); looks_like_token_query && (self.is_read(name) || matches_name(name, "get") || matches_name(name, "filter") || matches_name(name, "first") || matches_name(name, "one")) } pub fn is_mutation(&self, name: &str) -> bool { self.mutation_indicator_names .iter() .any(|pattern| matches_name(name, pattern)) } pub fn is_read(&self, name: &str) -> bool { self.read_indicator_names .iter() .any(|pattern| matches_name(name, pattern)) } pub fn has_expiry_field(&self, text: &str) -> bool { let lower = text.to_ascii_lowercase(); self.token_expiry_fields .iter() .map(|field| field.to_ascii_lowercase()) .any(|field| lower.contains(&field)) } pub fn has_recipient_field(&self, text: &str) -> bool { let lower = text.to_ascii_lowercase(); self.token_recipient_fields .iter() .map(|field| field.to_ascii_lowercase()) .any(|field| lower.contains(&field)) } pub fn rule_id(&self, suffix: &str) -> String { format!("{}.{}", self.finding_prefix, suffix) } } fn auth_finding_prefix(lang_slug: &str) -> Option<&'static str> { match lang_slug { "javascript" | "typescript" => Some("js.auth"), "python" => Some("py.auth"), "ruby" => Some("rb.auth"), "go" => Some("go.auth"), "java" => Some("java.auth"), "rust" => Some("rs.auth"), _ => None, } } fn auth_config_slugs(lang_slug: &str) -> &'static [&'static str] { match lang_slug { "typescript" => &["javascript", "typescript"], "javascript" => &["javascript"], "python" => &["python"], "ruby" => &["ruby"], "go" => &["go"], "java" => &["java"], "rust" => &["rust"], _ => &[], } } pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules { let Some(finding_prefix) = auth_finding_prefix(lang_slug) else { return AuthAnalysisRules::disabled(); }; let mut rules = if matches!(lang_slug, "python") { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "admin_required".into(), "staff_member_required".into(), "is_admin".into(), "is_staff".into(), "permission_required".into(), "PermissionRequiredMixin".into(), "AdminRequiredMixin".into(), ], login_guard_names: vec![ "login_required".into(), "LoginRequiredMixin".into(), "require_login".into(), "ensure_authenticated".into(), "require_auth".into(), ], authorization_check_names: vec![ "check_membership".into(), "has_membership".into(), "require_membership".into(), "ensure_membership".into(), "is_member".into(), "check_ownership".into(), "has_ownership".into(), "require_ownership".into(), "ensure_ownership".into(), "is_owner".into(), "owns_".into(), "permission_required".into(), "has_perm".into(), "has_permission".into(), "has_object_permission".into(), "user_passes_test".into(), "verify_access".into(), "authorize".into(), ], mutation_indicator_names: vec![ "update".into(), "delete".into(), "create".into(), "save".into(), "bulk_update".into(), "bulk_create".into(), "archive".into(), "publish".into(), "remove".into(), "add".into(), "confirm".into(), "invite".into(), "accept".into(), ], read_indicator_names: vec![ "get".into(), "filter".into(), "find".into(), "fetch".into(), "load".into(), "list".into(), "retrieve".into(), ], token_lookup_names: vec![ "find_by_token".into(), "lookup_by_token".into(), "get_by_token".into(), "get_invitation_by_token".into(), "Invitation.objects.get".into(), "invite_lookup".into(), ], token_expiry_fields: vec![ "expires_at".into(), "expiresat".into(), "expiry".into(), "expires".into(), "expired".into(), "has_expired".into(), ], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipientemail".into(), "invited_email".into(), "invitedemail".into(), "recipient".into(), ], non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } } else if matches!(lang_slug, "ruby") { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "require_admin".into(), "require_admin!".into(), "authenticate_admin".into(), "authenticate_admin!".into(), "ensure_admin".into(), "ensure_admin!".into(), "admin_only".into(), "admin_only!".into(), "admin_required".into(), "admin_required!".into(), ], login_guard_names: vec![ "require_login".into(), "require_login!".into(), "authenticate_user".into(), "authenticate_user!".into(), "authenticate".into(), "authenticate!".into(), "ensure_authenticated".into(), "ensure_authenticated!".into(), "login_required".into(), "login_required!".into(), ], authorization_check_names: vec![ "authorize".into(), "authorize!".into(), "check_membership".into(), "check_membership!".into(), "has_membership".into(), "has_membership?".into(), "require_membership".into(), "require_membership!".into(), "ensure_membership".into(), "ensure_membership!".into(), "member_of?".into(), "member?".into(), "check_ownership".into(), "check_ownership!".into(), "has_ownership".into(), "has_ownership?".into(), "require_ownership".into(), "require_ownership!".into(), "ensure_ownership".into(), "ensure_ownership!".into(), "owner?".into(), "owns?".into(), "verify_access".into(), "verify_access!".into(), "can_access?".into(), "can?".into(), ], mutation_indicator_names: vec![ "update".into(), "update!".into(), "delete".into(), "delete!".into(), "destroy".into(), "destroy!".into(), "create".into(), "create!".into(), "save".into(), "save!".into(), "archive".into(), "archive!".into(), "publish".into(), "publish!".into(), "remove".into(), "remove!".into(), "add".into(), "add!".into(), "confirm".into(), "confirm!".into(), "invite".into(), "invite!".into(), "accept".into(), "accept!".into(), ], read_indicator_names: vec![ "find".into(), "find_by".into(), "find_by!".into(), "where".into(), "first".into(), "last".into(), "take".into(), "pluck".into(), "load".into(), "fetch".into(), "get".into(), "lookup".into(), "retrieve".into(), ], token_lookup_names: vec![ "find_by_token".into(), "find_by_token!".into(), "find_by_invite_token".into(), "find_by_invite_token!".into(), "find_by_invitation_token".into(), "find_by_invitation_token!".into(), "find_by_accept_token".into(), "find_by_accept_token!".into(), "find_signed".into(), "find_signed!".into(), "lookup_invitation".into(), "lookup_invitation!".into(), "Invitation.find_by".into(), "Invitation.find_by!".into(), "Invite.find_by".into(), "Invite.find_by!".into(), ], token_expiry_fields: vec![ "expires_at".into(), "expiry".into(), "expires".into(), "expired".into(), "expired?".into(), "expired_at".into(), "valid_until".into(), ], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipient".into(), "invited_email".into(), "invitee_email".into(), "user_email".into(), ], non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } } else if matches!(lang_slug, "go") { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "RequireAdmin".into(), "AdminOnly".into(), "EnsureAdmin".into(), "requireAdmin".into(), "adminOnly".into(), "ensureAdmin".into(), ], login_guard_names: vec![ "RequireLogin".into(), "RequireAuth".into(), "EnsureAuthenticated".into(), "AuthMiddleware".into(), "requireLogin".into(), "requireAuth".into(), "ensureAuthenticated".into(), ], authorization_check_names: vec![ "CheckMembership".into(), "HasMembership".into(), "RequireMembership".into(), "EnsureMembership".into(), "IsMember".into(), "CheckOwnership".into(), "HasOwnership".into(), "RequireOwnership".into(), "EnsureOwnership".into(), "IsOwner".into(), "Authorize".into(), "VerifyAccess".into(), "HasPermission".into(), "CanAccess".into(), ], mutation_indicator_names: vec![ "Update".into(), "Delete".into(), "Create".into(), "Save".into(), "Archive".into(), "Publish".into(), "Remove".into(), "Add".into(), "Confirm".into(), "Invite".into(), "Accept".into(), ], read_indicator_names: vec![ "Find".into(), "Get".into(), "List".into(), "Load".into(), "Fetch".into(), "Lookup".into(), "Query".into(), ], token_lookup_names: vec![ "FindByToken".into(), "LookupByToken".into(), "FindInvitationByToken".into(), "FindInviteByToken".into(), "GetInvitationByToken".into(), "LookupInvitation".into(), ], token_expiry_fields: vec![ "expires_at".into(), "expiresat".into(), "expiresAt".into(), "expiry".into(), "expired".into(), "validUntil".into(), ], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipientEmail".into(), "invited_email".into(), "invitedEmail".into(), "invitee_email".into(), "inviteeEmail".into(), "recipient".into(), ], non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } } else if matches!(lang_slug, "java") { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "RequireAdmin".into(), "AdminOnly".into(), "EnsureAdmin".into(), "adminOnly".into(), ], login_guard_names: vec![ "RequireLogin".into(), "LoginRequired".into(), "EnsureAuthenticated".into(), "Authenticated".into(), "isAuthenticated".into(), ], authorization_check_names: vec![ "checkMembership".into(), "hasMembership".into(), "requireMembership".into(), "ensureMembership".into(), "isMember".into(), "checkOwnership".into(), "hasOwnership".into(), "requireOwnership".into(), "ensureOwnership".into(), "isOwner".into(), "authorize".into(), "verifyAccess".into(), "hasPermission".into(), "canAccess".into(), ], mutation_indicator_names: vec![ "update".into(), "delete".into(), "create".into(), "save".into(), "archive".into(), "publish".into(), "remove".into(), "add".into(), "confirm".into(), "invite".into(), "accept".into(), ], read_indicator_names: vec![ "find".into(), "get".into(), "load".into(), "fetch".into(), "lookup".into(), "read".into(), "query".into(), ], token_lookup_names: vec![ "findByToken".into(), "findByInviteToken".into(), "findByInvitationToken".into(), "findByAcceptToken".into(), "getByToken".into(), "lookupByToken".into(), "lookupInvitation".into(), ], token_expiry_fields: vec![ "expires_at".into(), "expiresAt".into(), "expiry".into(), "expired".into(), "validUntil".into(), ], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipientEmail".into(), "invited_email".into(), "invitedEmail".into(), "invitee_email".into(), "inviteeEmail".into(), "recipient".into(), ], non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } } else if matches!(lang_slug, "rust") { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "require_admin".into(), "ensure_admin".into(), "admin_only".into(), "admin_guard".into(), "AdminUser".into(), "AdminGuard".into(), "RequireAdmin".into(), ], login_guard_names: vec![ "require_login".into(), "require_auth".into(), "ensure_authenticated".into(), "authenticated".into(), "CurrentUser".into(), "SessionUser".into(), "AuthUser".into(), "RequireLogin".into(), "RequireAuth".into(), ], authorization_check_names: vec![ "check_membership".into(), "has_membership".into(), "require_membership".into(), "ensure_membership".into(), "is_member".into(), "check_ownership".into(), "has_ownership".into(), "require_ownership".into(), "ensure_ownership".into(), "is_owner".into(), "authorize".into(), "verify_access".into(), "has_permission".into(), "can_access".into(), "can_manage".into(), // Common project-specific helpers seen in real Axum/Rocket // codebases — kept as defaults so user code that names // its membership helper after the resource still gets // recognised. Users can extend via `nyx.toml`. "require_group_member".into(), "require_org_member".into(), "require_workspace_member".into(), "require_tenant_member".into(), "require_team_member".into(), ], mutation_indicator_names: vec![ "update".into(), "delete".into(), "destroy".into(), "create".into(), "save".into(), "archive".into(), "publish".into(), "remove".into(), "insert".into(), "add".into(), "confirm".into(), "invite".into(), "accept".into(), "set".into(), ], read_indicator_names: vec![ "find".into(), "find_by_id".into(), "get".into(), "load".into(), "fetch".into(), "lookup".into(), "list".into(), "read".into(), "query".into(), ], token_lookup_names: vec![ "find_by_token".into(), "lookup_by_token".into(), "get_by_token".into(), "find_invitation_by_token".into(), "find_invite_by_token".into(), "lookup_invitation".into(), "get_invitation".into(), "find_by_invite_token".into(), "find_by_invitation_token".into(), "find_signed".into(), ], token_expiry_fields: vec![ "expires_at".into(), "expiresat".into(), "expiresAt".into(), "expiry".into(), "expires".into(), "expired".into(), "valid_until".into(), "validUntil".into(), ], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipientEmail".into(), "invited_email".into(), "invitedEmail".into(), "invitee_email".into(), "inviteeEmail".into(), "recipient".into(), ], non_sink_receiver_types: vec![ "HashMap".into(), "HashSet".into(), "BTreeMap".into(), "BTreeSet".into(), "Vec".into(), "VecDeque".into(), "BinaryHeap".into(), "IndexMap".into(), "IndexSet".into(), "LinkedList".into(), "SmallVec".into(), "FxHashMap".into(), "FxHashSet".into(), "DashMap".into(), "DashSet".into(), // `serde_json::Map` (last-segment `Map`) — common JSON // body builder where `m.insert("k", v)` is a string-key // assignment on an in-memory object, not a DB write. "Map".into(), ], non_sink_receiver_name_prefixes: vec![ "local_map".into(), "local_set".into(), "local_cache".into(), "visited".into(), "seen".into(), "idx_".into(), "index_".into(), "lookup_".into(), "_tmp_map".into(), "counts".into(), "buckets".into(), "pending".into(), "queue".into(), "stack".into(), ], non_sink_global_receivers: Vec::new(), non_sink_method_names: Vec::new(), realtime_receiver_prefixes: vec![ "realtime".into(), "pubsub".into(), "broker".into(), "broadcast".into(), "notifier".into(), "channels".into(), ], outbound_network_receiver_prefixes: vec![ "http".into(), "reqwest".into(), "hyper".into(), "client".into(), "webhook".into(), "fetcher".into(), ], cache_receiver_prefixes: vec!["redis".into(), "memcache".into(), "memcached".into()], acl_tables: vec![ "group_members".into(), "org_memberships".into(), "workspace_members".into(), "tenant_members".into(), "members".into(), "share_grants".into(), ], } } else { AuthAnalysisRules { enabled: true, finding_prefix: finding_prefix.into(), admin_path_patterns: vec!["/admin/".into()], admin_guard_names: vec![ "requireAdmin".into(), "isAdmin".into(), "adminOnly".into(), "requireRole".into(), ], login_guard_names: vec![ "requireLogin".into(), "authenticate".into(), "requireAuth".into(), "ensureAuthenticated".into(), "ensureAuth".into(), "require_login".into(), ], authorization_check_names: vec![ "checkMembership".into(), "hasWorkspaceMembership".into(), "checkOwnership".into(), "authorize".into(), "hasAccess".into(), "isOwner".into(), "isMember".into(), "requireMembership".into(), "requireOwnership".into(), "verifyAccess".into(), "hasPermission".into(), "requireRole".into(), "canAccess".into(), ], mutation_indicator_names: vec![ "update".into(), "delete".into(), "create".into(), "archive".into(), "publish".into(), "remove".into(), "insert".into(), "add".into(), "confirm".into(), "invite".into(), "run".into(), "accept".into(), ], read_indicator_names: vec![ "findById".into(), "find".into(), "list".into(), "get".into(), "fetch".into(), "load".into(), ], token_lookup_names: vec!["findByToken".into(), "lookupByToken".into()], token_expiry_fields: vec!["expires_at".into(), "expiresAt".into(), "expiry".into()], token_recipient_fields: vec![ "email".into(), "recipient_email".into(), "recipientEmail".into(), "invited_email".into(), "invitedEmail".into(), ], non_sink_receiver_types: Vec::new(), non_sink_receiver_name_prefixes: Vec::new(), // Browser/DOM globals — calls on these receivers are // categorically client-side (no server-side authorization // semantics). Without this list, `document.getElementById` // would prefix-match the read-indicator `get`, // `window.scrollTo` would match `scroll`, etc. Case-sensitive // exact match against the first receiver-chain segment. non_sink_global_receivers: vec![ "document".into(), "window".into(), "localStorage".into(), "sessionStorage".into(), "console".into(), "navigator".into(), "location".into(), "history".into(), "screen".into(), "performance".into(), "crypto".into(), "Math".into(), "JSON".into(), "Date".into(), "Number".into(), "String".into(), "Boolean".into(), "Array".into(), "Object".into(), "Promise".into(), "Symbol".into(), "RegExp".into(), "Error".into(), "Map".into(), "Set".into(), "WeakMap".into(), "WeakSet".into(), ], // DOM-API methods — when the LAST segment of the callee // matches, the call is non-data-layer regardless of receiver // (`el.addEventListener`, `parent.appendChild`). These // methods would otherwise prefix-match `add`, `remove`, // `get`, `set` indicators. non_sink_method_names: vec![ "addEventListener".into(), "removeEventListener".into(), "dispatchEvent".into(), "appendChild".into(), "removeChild".into(), "replaceChild".into(), "insertBefore".into(), "cloneNode".into(), "getElementById".into(), "getElementsByClassName".into(), "getElementsByTagName".into(), "getElementsByName".into(), "querySelector".into(), "querySelectorAll".into(), "getAttribute".into(), "setAttribute".into(), "removeAttribute".into(), "hasAttribute".into(), "toggleAttribute".into(), "createElement".into(), "createTextNode".into(), "createDocumentFragment".into(), "getBoundingClientRect".into(), "getComputedStyle".into(), "scrollIntoView".into(), "scrollTo".into(), "scrollBy".into(), "focus".into(), "blur".into(), "submit".into(), "reset".into(), "click".into(), "matches".into(), "contains".into(), "closest".into(), "getItem".into(), "setItem".into(), "removeItem".into(), ], realtime_receiver_prefixes: Vec::new(), outbound_network_receiver_prefixes: Vec::new(), cache_receiver_prefixes: Vec::new(), acl_tables: Vec::new(), } }; for config_slug in auth_config_slugs(lang_slug) { let Some(lang_cfg) = config.analysis.languages.get(*config_slug) else { continue; }; rules.enabled = lang_cfg.auth.enabled; extend_unique( &mut rules.admin_path_patterns, &lang_cfg.auth.admin_path_patterns, ); extend_unique( &mut rules.admin_guard_names, &lang_cfg.auth.admin_guard_names, ); extend_unique( &mut rules.login_guard_names, &lang_cfg.auth.login_guard_names, ); extend_unique( &mut rules.authorization_check_names, &lang_cfg.auth.authorization_check_names, ); extend_unique( &mut rules.mutation_indicator_names, &lang_cfg.auth.mutation_indicator_names, ); extend_unique( &mut rules.read_indicator_names, &lang_cfg.auth.read_indicator_names, ); extend_unique( &mut rules.token_lookup_names, &lang_cfg.auth.token_lookup_names, ); extend_unique( &mut rules.token_expiry_fields, &lang_cfg.auth.token_expiry_fields, ); extend_unique( &mut rules.token_recipient_fields, &lang_cfg.auth.token_recipient_fields, ); extend_unique( &mut rules.non_sink_receiver_types, &lang_cfg.auth.non_sink_receiver_types, ); extend_unique( &mut rules.non_sink_receiver_name_prefixes, &lang_cfg.auth.non_sink_receiver_name_prefixes, ); extend_unique( &mut rules.non_sink_global_receivers, &lang_cfg.auth.non_sink_global_receivers, ); extend_unique( &mut rules.non_sink_method_names, &lang_cfg.auth.non_sink_method_names, ); extend_unique( &mut rules.realtime_receiver_prefixes, &lang_cfg.auth.realtime_receiver_prefixes, ); extend_unique( &mut rules.outbound_network_receiver_prefixes, &lang_cfg.auth.outbound_network_receiver_prefixes, ); extend_unique( &mut rules.cache_receiver_prefixes, &lang_cfg.auth.cache_receiver_prefixes, ); extend_unique(&mut rules.acl_tables, &lang_cfg.auth.acl_tables); } rules } pub fn extend_unique(dst: &mut Vec, src: &[String]) { for item in src { if !dst.contains(item) { dst.push(item.clone()); } } } pub fn canonical_name(name: &str) -> String { name.chars() .filter(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_lowercase()) .collect() } /// Return the first segment of a callee's receiver chain. /// For `map.insert` → `"map"`; for `self.cache.insert` → `"self"`; /// for a callee with no receiver (`HashMap::new`) → the full name. pub fn first_receiver_segment(callee: &str) -> &str { callee.split('.').next().unwrap_or(callee) } /// Recognise `require__` / `ensure__` /// shapes where `` is a closed-vocabulary authorization noun /// (`member`, `owner`, `admin`, `access`, `permission`, `manager`, /// `editor`, `viewer`). The resource segment is project-specific /// (`trip`, `doc`, `project`, `workspace`, …) and cannot be enumerated /// in the static defaults — but the prefix+role pattern is unambiguous /// enough that recognising it as an authorization check is safe. /// /// Strips path-namespace and method prefixes before matching: /// `authz::require_trip_member` → `require_trip_member`; /// `obj.require_trip_member` → `require_trip_member`. fn is_require_resource_role_call(name: &str) -> bool { let last = name.rsplit("::").next().unwrap_or(name); let last = last.rsplit('.').next().unwrap_or(last); let lower = last.to_ascii_lowercase(); let after_prefix = if let Some(rest) = lower.strip_prefix("require_") { rest } else if let Some(rest) = lower.strip_prefix("ensure_") { rest } else { return false; }; let Some(last_underscore) = after_prefix.rfind('_') else { return false; }; // Must have at least one resource char before the role and a // non-empty role after. Rejects degenerate `require__member`, // `require_member` (no resource). if last_underscore == 0 || last_underscore == after_prefix.len() - 1 { return false; } let role = &after_prefix[last_underscore + 1..]; matches!( role, "member" | "members" | "owner" | "owners" | "admin" | "admins" | "access" | "permission" | "permissions" | "manager" | "managers" | "editor" | "editors" | "viewer" | "viewers" | "role" ) } pub fn matches_name(name: &str, pattern: &str) -> bool { let name_last = name.rsplit('.').next().unwrap_or(name); let pattern_last = pattern.rsplit('.').next().unwrap_or(pattern); let name_norm = canonical_name(name_last); let pattern_norm = canonical_name(pattern_last); !pattern_norm.is_empty() && (name_norm == pattern_norm || name_norm.starts_with(&pattern_norm)) } pub fn strip_quotes(input: &str) -> String { input .trim() .trim_matches('\'') .trim_matches('"') .trim_matches('`') .to_string() } #[cfg(test)] mod tests { use super::build_auth_rules; use crate::utils::config::{AuthAnalysisConfig, Config, LanguageAnalysisConfig}; #[test] fn typescript_uses_javascript_rule_prefix() { let cfg = Config::default(); let rules = build_auth_rules(&cfg, "typescript"); assert_eq!( rules.rule_id("missing_ownership_check"), "js.auth.missing_ownership_check" ); } #[test] fn typescript_inherits_javascript_auth_overrides_and_applies_ts_specific_overlay() { let mut cfg = Config::default(); cfg.analysis.languages.insert( "javascript".into(), LanguageAnalysisConfig { auth: AuthAnalysisConfig { admin_guard_names: vec!["requirePlatformAdmin".into()], token_lookup_names: vec!["findInviteToken".into()], ..AuthAnalysisConfig::default() }, ..LanguageAnalysisConfig::default() }, ); cfg.analysis.languages.insert( "typescript".into(), LanguageAnalysisConfig { auth: AuthAnalysisConfig { authorization_check_names: vec!["requireTypedOwnership".into()], ..AuthAnalysisConfig::default() }, ..LanguageAnalysisConfig::default() }, ); let rules = build_auth_rules(&cfg, "typescript"); assert!( rules .admin_guard_names .contains(&"requirePlatformAdmin".to_string()) ); assert!( rules .token_lookup_names .contains(&"findInviteToken".to_string()) ); assert!( rules .authorization_check_names .contains(&"requireTypedOwnership".to_string()) ); } #[test] fn rust_non_sink_receiver_defaults_include_std_collections() { let cfg = Config::default(); let rules = build_auth_rules(&cfg, "rust"); assert!(rules.is_non_sink_receiver_type("HashMap")); assert!(rules.is_non_sink_receiver_type("HashSet")); assert!(rules.is_non_sink_receiver_type("Vec")); assert!(rules.is_non_sink_receiver_type("std::collections::HashMap")); assert!(rules.is_non_sink_receiver_type("HashMap")); assert!(!rules.is_non_sink_receiver_type("Database")); } #[test] fn rust_non_sink_constructor_callee_matches_known_forms() { let cfg = Config::default(); let rules = build_auth_rules(&cfg, "rust"); assert!(rules.is_non_sink_constructor_callee("HashMap::new")); assert!(rules.is_non_sink_constructor_callee("HashMap::with_capacity")); assert!(rules.is_non_sink_constructor_callee("SmallVec::from")); assert!(rules.is_non_sink_constructor_callee("std::collections::HashMap::new")); assert!(!rules.is_non_sink_constructor_callee("HashMap::get")); assert!(!rules.is_non_sink_constructor_callee("Database::connect")); assert!(!rules.is_non_sink_constructor_callee("plain_function")); } #[test] fn callee_has_non_sink_receiver_matches_var_set_and_prefixes() { use std::collections::HashSet; let cfg = Config::default(); let rules = build_auth_rules(&cfg, "rust"); let mut vars = HashSet::new(); vars.insert("map".to_string()); // First receiver segment in non_sink_vars → skipped. assert!(rules.callee_has_non_sink_receiver("map.insert", &vars)); // First segment not in vars, not a known prefix → not skipped. assert!(!rules.callee_has_non_sink_receiver("db.insert", &vars)); // Deep receiver: "self.cache.insert" → first segment "self" → ambiguous. assert!(!rules.callee_has_non_sink_receiver("self.cache.insert", &vars)); // Prefix-match on configured name prefix ("counts" is in defaults). assert!(rules.callee_has_non_sink_receiver("counts.insert", &HashSet::new())); assert!(rules.callee_has_non_sink_receiver("visited_nodes.insert", &HashSet::new())); } #[test] fn classify_sink_class_dispatches_on_receiver_and_name() { use crate::auth_analysis::model::SinkClass; use std::collections::HashSet; let cfg = Config::default(); let rules = build_auth_rules(&cfg, "rust"); let mut vars = HashSet::new(); vars.insert("map".to_string()); // In-memory local: tracked var → InMemoryLocal (trumps name-based match). assert_eq!( rules.classify_sink_class("map.insert", &vars), Some(SinkClass::InMemoryLocal) ); // In-memory local: configured name prefix. assert_eq!( rules.classify_sink_class("visited.insert", &HashSet::new()), Some(SinkClass::InMemoryLocal) ); // Realtime: default prefix `realtime` → RealtimePublish even when // the method name (`publish_to_group`) would also match the // mutation list. assert_eq!( rules.classify_sink_class("realtime.publish_to_group", &HashSet::new()), Some(SinkClass::RealtimePublish) ); // Outbound network: default prefix `http`. assert_eq!( rules.classify_sink_class("http.post", &HashSet::new()), Some(SinkClass::OutboundNetwork) ); // Cache: default prefix `redis`. assert_eq!( rules.classify_sink_class("redis.set", &HashSet::new()), Some(SinkClass::CacheCrossTenant) ); // DB mutation fallback: `db.insert` → mutation indicator → // DbMutation (no receiver prefix matches `db`). assert_eq!( rules.classify_sink_class("db.insert", &HashSet::new()), Some(SinkClass::DbMutation) ); // DB cross-tenant read fallback: `db.find_by_id` → read indicator. assert_eq!( rules.classify_sink_class("db.find_by_id", &HashSet::new()), Some(SinkClass::DbCrossTenantRead) ); // Unknown verb with unknown receiver → None. assert_eq!( rules.classify_sink_class("widget.frobnicate", &HashSet::new()), None ); } #[test] fn sink_class_is_auth_relevant_only_for_non_local_classes() { use crate::auth_analysis::model::SinkClass; assert!(SinkClass::DbMutation.is_auth_relevant()); assert!(SinkClass::DbCrossTenantRead.is_auth_relevant()); assert!(SinkClass::RealtimePublish.is_auth_relevant()); assert!(SinkClass::OutboundNetwork.is_auth_relevant()); assert!(SinkClass::CacheCrossTenant.is_auth_relevant()); assert!(!SinkClass::InMemoryLocal.is_auth_relevant()); } /// Pin the JS DOM-globals / DOM-methods allowlist that closes the /// real-repo FP cluster of `document.getElementById` / /// `el.addEventListener` shapes prefix-matching read/mutation /// indicators (`get`, `add`). #[test] fn js_dom_globals_and_methods_classify_as_in_memory_local() { use crate::auth_analysis::model::SinkClass; use std::collections::HashSet; let cfg = Config::default(); let rules = build_auth_rules(&cfg, "javascript"); let empty: HashSet = HashSet::new(); // Globals — receiver-first-segment match. assert_eq!( rules.classify_sink_class("document.getElementById", &empty), Some(SinkClass::InMemoryLocal) ); assert_eq!( rules.classify_sink_class("window.scrollTo", &empty), Some(SinkClass::InMemoryLocal) ); assert_eq!( rules.classify_sink_class("localStorage.getItem", &empty), Some(SinkClass::InMemoryLocal) ); assert_eq!( rules.classify_sink_class("Math.random", &empty), Some(SinkClass::InMemoryLocal) ); // Method allowlist — last-segment match regardless of receiver. assert_eq!( rules.classify_sink_class("input.addEventListener", &empty), Some(SinkClass::InMemoryLocal) ); assert_eq!( rules.classify_sink_class("dropdown.appendChild", &empty), Some(SinkClass::InMemoryLocal) ); assert_eq!( rules.classify_sink_class("el.querySelector", &empty), Some(SinkClass::InMemoryLocal) ); // Real data-layer reads/mutations on plausible names still // classify (no over-suppression): `db.find_by_id` reads, // `repo.save` mutates. assert_eq!( rules.classify_sink_class("UserRepo.findById", &empty), Some(SinkClass::DbCrossTenantRead) ); assert_eq!( rules.classify_sink_class("repo.update", &empty), Some(SinkClass::DbMutation) ); } /// `require__` structural recogniser for project /// helpers like `require_trip_member`, `require_doc_owner`. #[test] fn is_authorization_check_recognises_require_resource_role_shapes() { let cfg = Config::default(); let rules = build_auth_rules(&cfg, "rust"); assert!(rules.is_authorization_check("require_trip_member")); assert!(rules.is_authorization_check("require_doc_owner")); assert!(rules.is_authorization_check("require_project_admin")); assert!(rules.is_authorization_check("ensure_workspace_access")); assert!(rules.is_authorization_check("authz::require_trip_member")); assert!(rules.is_authorization_check("self.require_album_editor")); // Negatives — random `require_*` calls without a known role // suffix do NOT count as authorization. assert!(!rules.is_authorization_check("require_db")); assert!(!rules.is_authorization_check("require_user")); assert!(!rules.is_authorization_check("require_login")); // Bare `require_member` / `require_owner` (no resource segment) // aren't enough — the resource segment is what makes the helper // unambiguous. assert!(!rules.is_authorization_check("require_member")); assert!(!rules.is_authorization_check("require_owner")); } }