From f49211d788e93ca1ab8bb6a1df377425503e314d Mon Sep 17 00:00:00 2001 From: elipeter Date: Sun, 24 May 2026 14:34:39 -0500 Subject: [PATCH] refactor(dynamic): enhance resolver detection for frameworks, refine SSA receiver validation, and expand test coverage --- .../framework/adapters/graphql_gqlgen.rs | 33 +++- .../framework/adapters/graphql_graphene.rs | 23 ++- .../framework/adapters/graphql_juniper.rs | 70 ++++++- .../framework/adapters/graphql_relay.rs | 22 ++- .../framework/adapters/middleware_django.rs | 73 +++++++- .../framework/adapters/middleware_laravel.rs | 23 ++- .../framework/adapters/middleware_spring.rs | 36 ++-- .../framework/adapters/migration_django.rs | 41 +++-- .../framework/adapters/migration_flask.rs | 38 ++-- .../framework/adapters/migration_sequelize.rs | 30 ++- .../framework/adapters/scheduled_celery.rs | 135 ++++++++++++-- .../framework/adapters/scheduled_quartz.rs | 149 +++++++++++++-- .../adapters/websocket_actioncable.rs | 31 ++-- .../framework/adapters/websocket_channels.rs | 36 ++-- .../framework/adapters/websocket_socketio.rs | 55 ++++-- .../expectations.json | 16 ++ .../node_non_sqs_send.js | 19 ++ .../python_non_broker_handler.py | 16 ++ .../python_non_rabbit_process.py | 13 ++ .../expectations.json | 16 ++ .../go_gqlgen_helper.go | 14 ++ .../java_quartz_queue_schedule.java | 15 ++ .../java_spring_middleware_helper.java | 11 ++ .../js_relay_helper.js | 11 ++ .../js_sequelize_helper.js | 15 ++ .../php_laravel_bootstrapper.php | 9 + .../python_alembic_helper.py | 11 ++ .../python_celery_mailer_delay.py | 16 ++ .../python_channels_helper.py | 10 + .../python_django_middleware_helper.py | 10 + .../python_django_migration_helper.py | 11 ++ .../python_graphene_helper.py | 12 ++ .../python_socketio_helper.py | 12 ++ .../ruby_actioncable_helper.rb | 13 ++ .../rust_juniper_helper.rs | 14 ++ tests/integration_tests.rs | 21 +++ tests/message_handler_corpus.rs | 84 ++++++++- tests/phase21_corpus.rs | 171 +++++++++++++++++- 38 files changed, 1198 insertions(+), 137 deletions(-) create mode 100644 tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json create mode 100644 tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js create mode 100644 tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py create mode 100644 tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb create mode 100644 tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs diff --git a/src/dynamic/framework/adapters/graphql_gqlgen.rs b/src/dynamic/framework/adapters/graphql_gqlgen.rs index 3cd75f98..89f13cd1 100644 --- a/src/dynamic/framework/adapters/graphql_gqlgen.rs +++ b/src/dynamic/framework/adapters/graphql_gqlgen.rs @@ -39,6 +39,19 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) { ("Query".to_owned(), summary.name.clone()) } +fn name_is_gqlgen_resolver(name: &str, file_bytes: &[u8]) -> bool { + if name.starts_with("Resolve") { + return true; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + text.contains(&format!("*queryResolver) {name}(")) + || text.contains(&format!("*mutationResolver) {name}(")) + || text.contains(&format!("*subscriptionResolver) {name}(")) +} + impl FrameworkAdapter for GraphqlGqlgenAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -56,7 +69,7 @@ impl FrameworkAdapter for GraphqlGqlgenAdapter { ) -> Option { let matches_call = super::any_callee_matches(summary, callee_is_gqlgen); let matches_source = source_imports_gqlgen(file_bytes); - if matches_call || matches_source { + if matches_source && (name_is_gqlgen_resolver(&summary.name, file_bytes) || matches_call) { let (type_name, field) = extract_resolver(summary); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -100,4 +113,22 @@ mod tests { assert_eq!(binding.adapter, "graphql-gqlgen"); assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); } + + #[test] + fn skips_unrelated_helper_in_gqlgen_file() { + let src: &[u8] = b"package graph\n\ + import \"github.com/99designs/gqlgen/graphql\"\n\ + type queryResolver struct{}\n\ + func NormalizeID(id string) string { return id }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "NormalizeID".into(), + ..Default::default() + }; + assert!( + GraphqlGqlgenAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/graphql_graphene.rs b/src/dynamic/framework/adapters/graphql_graphene.rs index 93216770..884c725a 100644 --- a/src/dynamic/framework/adapters/graphql_graphene.rs +++ b/src/dynamic/framework/adapters/graphql_graphene.rs @@ -42,6 +42,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) { ("Query".to_owned(), summary.name.clone()) } +fn name_is_graphene_resolver(name: &str) -> bool { + name.starts_with("resolve_") +} + impl FrameworkAdapter for GraphqlGrapheneAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -59,7 +63,7 @@ impl FrameworkAdapter for GraphqlGrapheneAdapter { ) -> Option { let matches_call = super::any_callee_matches(summary, callee_is_graphene); let matches_source = source_imports_graphene(file_bytes); - if matches_call || matches_source { + if matches_source && (name_is_graphene_resolver(&summary.name) || matches_call) { let (type_name, field) = extract_resolver(summary); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -104,4 +108,21 @@ mod tests { assert_eq!(field, "user"); } } + + #[test] + fn skips_unrelated_helper_in_graphene_file() { + let src: &[u8] = b"import graphene\n\ + class Query(graphene.ObjectType):\n user = graphene.String()\n\ + def normalize_id(raw):\n return str(raw)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize_id".into(), + ..Default::default() + }; + assert!( + GraphqlGrapheneAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/graphql_juniper.rs b/src/dynamic/framework/adapters/graphql_juniper.rs index 2b816bcb..f03f5c03 100644 --- a/src/dynamic/framework/adapters/graphql_juniper.rs +++ b/src/dynamic/framework/adapters/graphql_juniper.rs @@ -37,6 +37,39 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) { ("Query".to_owned(), summary.name.clone()) } +fn name_is_juniper_resolver(name: &str, file_bytes: &[u8]) -> bool { + if name.starts_with("resolve_") { + return true; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let needle = format!("fn {name}("); + let mut search_from = 0; + while let Some(rel_idx) = text[search_from..].find(&needle) { + let fn_idx = search_from + rel_idx; + let before = &text[..fn_idx]; + let Some(impl_idx) = before.rfind("impl ") else { + search_from = fn_idx + needle.len(); + continue; + }; + if before[impl_idx..].contains('}') { + search_from = fn_idx + needle.len(); + continue; + } + let scope_start = before[..impl_idx] + .rfind('}') + .map(|idx| idx + 1) + .unwrap_or(0); + if before[scope_start..impl_idx].contains("#[graphql_object") { + return true; + } + search_from = fn_idx + needle.len(); + } + false +} + impl FrameworkAdapter for GraphqlJuniperAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -54,7 +87,7 @@ impl FrameworkAdapter for GraphqlJuniperAdapter { ) -> Option { let matches_call = super::any_callee_matches(summary, callee_is_juniper); let matches_source = source_imports_juniper(file_bytes); - if matches_call || matches_source { + if matches_source && (name_is_juniper_resolver(&summary.name, file_bytes) || matches_call) { let (type_name, field) = extract_resolver(summary); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -98,4 +131,39 @@ mod tests { assert_eq!(binding.adapter, "graphql-juniper"); assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); } + + #[test] + fn skips_unrelated_helper_in_juniper_file() { + let src: &[u8] = b"use juniper::RootNode;\n\ + pub fn normalize_id(id: &str) -> String { id.to_string() }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "normalize_id".into(), + ..Default::default() + }; + assert!( + GraphqlJuniperAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_free_helper_next_to_graphql_object_impl() { + let src: &[u8] = b"use juniper::graphql_object;\n\ + pub struct Query;\n\ + #[graphql_object]\n\ + impl Query {\n fn user(&self, id: String) -> String { id }\n}\n\ + pub fn normalize_id(id: &str) -> String { id.to_string() }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "normalize_id".into(), + ..Default::default() + }; + assert!( + GraphqlJuniperAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/graphql_relay.rs b/src/dynamic/framework/adapters/graphql_relay.rs index 46983070..57cbe9c2 100644 --- a/src/dynamic/framework/adapters/graphql_relay.rs +++ b/src/dynamic/framework/adapters/graphql_relay.rs @@ -49,6 +49,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) { ("Node".to_owned(), summary.name.clone()) } +fn name_is_relay_resolver(name: &str) -> bool { + name.starts_with("resolve") || name.ends_with("Resolver") +} + impl FrameworkAdapter for GraphqlRelayAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -66,7 +70,7 @@ impl FrameworkAdapter for GraphqlRelayAdapter { ) -> Option { let matches_call = super::any_callee_matches(summary, callee_is_relay); let matches_source = source_imports_relay(file_bytes); - if matches_call || matches_source { + if matches_source && (name_is_relay_resolver(&summary.name) || matches_call) { let (type_name, field) = extract_resolver(summary); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -108,4 +112,20 @@ mod tests { assert_eq!(binding.adapter, "graphql-relay"); assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); } + + #[test] + fn skips_unrelated_helper_in_relay_file() { + let src: &[u8] = b"const { nodeDefinitions } = require('graphql-relay');\n\ + function normalizeId(id) { return String(id); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "normalizeId".into(), + ..Default::default() + }; + assert!( + GraphqlRelayAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/middleware_django.rs b/src/dynamic/framework/adapters/middleware_django.rs index 2bac7d03..b0a8424a 100644 --- a/src/dynamic/framework/adapters/middleware_django.rs +++ b/src/dynamic/framework/adapters/middleware_django.rs @@ -17,14 +17,6 @@ pub struct MiddlewareDjangoAdapter; const ADAPTER_NAME: &str = "middleware-django"; -fn callee_is_django_middleware(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "process_request" | "process_response" | "process_view" | "process_exception" - ) -} - fn source_has_middleware_shape(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"django.utils.deprecation", @@ -38,6 +30,31 @@ fn source_has_middleware_shape(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +fn name_is_django_middleware_entry(name: &str) -> bool { + matches!( + name, + "__call__" | "process_request" | "process_response" | "process_view" | "process_exception" + ) +} + +fn name_wraps_django_middleware(name: &str, file_bytes: &[u8]) -> bool { + if name.is_empty() { + return false; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let needle = format!("def {name}("); + let Some(start) = text.find(&needle) else { + return false; + }; + let rest = &text[start..]; + let end = rest.find("\ndef ").unwrap_or(rest.len()); + let body = &rest[..end]; + body.contains("Middleware(") && body.contains("return ") +} + fn looks_like_settings_module(file_bytes: &[u8]) -> bool { // Heuristic: settings.py declares MIDDLEWARE / INSTALLED_APPS / DATABASES at // module scope. A real middleware module declares none of these (it carries @@ -75,9 +92,11 @@ impl FrameworkAdapter for MiddlewareDjangoAdapter { if looks_like_settings_module(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_django_middleware); let matches_source = source_has_middleware_shape(file_bytes); - if matches_call || matches_source { + if matches_source + && (name_is_django_middleware_entry(&summary.name) + || name_wraps_django_middleware(&summary.name, file_bytes)) + { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::Middleware { @@ -136,4 +155,38 @@ mod tests { "settings.py-shaped module must not bind as middleware", ); } + + #[test] + fn skips_unrelated_helper_in_django_middleware_file() { + let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\ + class AuditMiddleware(MiddlewareMixin):\n def process_request(self, request):\n pass\n\ + def normalize_request(request):\n return request\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize_request".into(), + ..Default::default() + }; + assert!( + MiddlewareDjangoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn binds_module_level_factory_wrapper() { + let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\ + class AuditMiddleware(MiddlewareMixin):\n def __call__(self, request):\n pass\n\ + def audit(get_response):\n return AuditMiddleware(get_response)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "audit".into(), + ..Default::default() + }; + assert!( + MiddlewareDjangoAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/middleware_laravel.rs b/src/dynamic/framework/adapters/middleware_laravel.rs index 6b382713..f6179323 100644 --- a/src/dynamic/framework/adapters/middleware_laravel.rs +++ b/src/dynamic/framework/adapters/middleware_laravel.rs @@ -59,7 +59,7 @@ impl FrameworkAdapter for MiddlewareLaravelAdapter { let has_shape = source_has_middleware_shape(file_bytes); let name_matches = name_is_middleware_entry(&summary.name); let body_mounts_middleware = - super::any_callee_matches(summary, callee_is_laravel_middleware); + has_shape && super::any_callee_matches(summary, callee_is_laravel_middleware); let binds = (name_matches && has_shape) || body_mounts_middleware; if !binds { return None; @@ -118,4 +118,25 @@ mod tests { "controller method must not bind as middleware just because the file imports Request", ); } + + #[test] + fn does_not_bind_with_middleware_call_without_contract_shape() { + let src: &[u8] = b"withMiddleware([]); }\n}\n"; + let tree = parse_php(src); + let mut summary = FuncSummary { + name: "configure".into(), + ..Default::default() + }; + summary.callees.push(crate::summary::CalleeSite { + name: "app.withMiddleware".to_owned(), + receiver: Some("app".to_owned()), + ordinal: 0, + ..Default::default() + }); + assert!( + MiddlewareLaravelAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/middleware_spring.rs b/src/dynamic/framework/adapters/middleware_spring.rs index e87a500d..114734a7 100644 --- a/src/dynamic/framework/adapters/middleware_spring.rs +++ b/src/dynamic/framework/adapters/middleware_spring.rs @@ -14,14 +14,6 @@ pub struct MiddlewareSpringAdapter; const ADAPTER_NAME: &str = "middleware-spring"; -fn callee_is_spring_middleware(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors" - ) -} - fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"HandlerInterceptor", @@ -36,6 +28,13 @@ fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +fn name_is_spring_middleware_entry(name: &str) -> bool { + matches!( + name, + "preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors" + ) +} + impl FrameworkAdapter for MiddlewareSpringAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -51,9 +50,9 @@ impl FrameworkAdapter for MiddlewareSpringAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_spring_middleware); - let matches_source = source_imports_spring_middleware(file_bytes); - if matches_call || matches_source { + if source_imports_spring_middleware(file_bytes) + && name_is_spring_middleware_entry(&summary.name) + { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::Middleware { @@ -95,4 +94,19 @@ mod tests { assert_eq!(binding.adapter, "middleware-spring"); assert!(matches!(binding.kind, EntryKind::Middleware { .. })); } + + #[test] + fn skips_unrelated_helper_in_spring_middleware_file() { + let src: &[u8] = b"public class AuditInterceptor implements HandlerInterceptor {\n public boolean preHandle(Object req, Object res, Object handler) { return true; }\n public String normalize(String payload) { return payload; }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "normalize".into(), + ..Default::default() + }; + assert!( + MiddlewareSpringAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/migration_django.rs b/src/dynamic/framework/adapters/migration_django.rs index 73a3b7dd..27cc6ba8 100644 --- a/src/dynamic/framework/adapters/migration_django.rs +++ b/src/dynamic/framework/adapters/migration_django.rs @@ -12,20 +12,6 @@ pub struct MigrationDjangoAdapter; const ADAPTER_NAME: &str = "migration-django"; -fn callee_is_django_migration(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "CreateModel" - | "AddField" - | "AlterField" - | "DeleteModel" - | "RunPython" - | "RunSQL" - | "migrate" - ) -} - fn source_imports_django_migration(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"django.db.migrations", @@ -56,6 +42,13 @@ fn extract_version(file_bytes: &[u8]) -> Option { None } +fn name_is_django_migration_entry(name: &str) -> bool { + matches!( + name, + "Migration" | "upgrade" | "downgrade" | "forwards" | "backwards" + ) +} + impl FrameworkAdapter for MigrationDjangoAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -71,9 +64,8 @@ impl FrameworkAdapter for MigrationDjangoAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_django_migration); let matches_source = source_imports_django_migration(file_bytes); - if matches_call || matches_source { + if matches_source && name_is_django_migration_entry(&summary.name) { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::Migration { @@ -116,4 +108,21 @@ mod tests { assert_eq!(binding.adapter, "migration-django"); assert!(matches!(binding.kind, EntryKind::Migration { .. })); } + + #[test] + fn skips_unrelated_helper_in_django_migration_file() { + let src: &[u8] = b"from django.db import migrations\n\ + class Migration(migrations.Migration):\n operations = [migrations.CreateModel(name='User', fields=[])]\n\ + def normalize_name(name):\n return str(name)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize_name".into(), + ..Default::default() + }; + assert!( + MigrationDjangoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/migration_flask.rs b/src/dynamic/framework/adapters/migration_flask.rs index bd88ed22..5a1ca637 100644 --- a/src/dynamic/framework/adapters/migration_flask.rs +++ b/src/dynamic/framework/adapters/migration_flask.rs @@ -13,20 +13,6 @@ pub struct MigrationFlaskAdapter; const ADAPTER_NAME: &str = "migration-flask"; -fn callee_is_flask_migration(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "upgrade" - | "downgrade" - | "execute" - | "create_table" - | "add_column" - | "drop_table" - | "alter_column" - ) -} - fn source_imports_flask_migration(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"from alembic", @@ -57,6 +43,10 @@ fn extract_version(file_bytes: &[u8]) -> Option { None } +fn name_is_flask_migration_entry(name: &str) -> bool { + matches!(name, "upgrade" | "downgrade") +} + impl FrameworkAdapter for MigrationFlaskAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -72,9 +62,8 @@ impl FrameworkAdapter for MigrationFlaskAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_flask_migration); let matches_source = source_imports_flask_migration(file_bytes); - if matches_call || matches_source { + if matches_source && name_is_flask_migration_entry(&summary.name) { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::Migration { @@ -119,4 +108,21 @@ mod tests { assert_eq!(version.as_deref(), Some("abc123")); } } + + #[test] + fn skips_unrelated_helper_in_alembic_file() { + let src: &[u8] = b"from alembic import op\nrevision = 'abc123'\n\ + def upgrade():\n op.create_table('users')\n\ + def normalize_name(name):\n return str(name)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize_name".into(), + ..Default::default() + }; + assert!( + MigrationFlaskAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/migration_sequelize.rs b/src/dynamic/framework/adapters/migration_sequelize.rs index 94e44e44..df05537d 100644 --- a/src/dynamic/framework/adapters/migration_sequelize.rs +++ b/src/dynamic/framework/adapters/migration_sequelize.rs @@ -13,14 +13,6 @@ pub struct MigrationSequelizeAdapter; const ADAPTER_NAME: &str = "migration-sequelize"; -fn callee_is_sequelize_migration(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "up" | "down" | "createTable" | "addColumn" | "dropTable" | "removeColumn" | "addIndex" - ) -} - fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"require('sequelize')", @@ -37,6 +29,10 @@ fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +fn name_is_sequelize_migration_entry(name: &str) -> bool { + matches!(name, "up" | "down") +} + impl FrameworkAdapter for MigrationSequelizeAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -52,9 +48,8 @@ impl FrameworkAdapter for MigrationSequelizeAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_sequelize_migration); let matches_source = source_imports_sequelize_migration(file_bytes); - if matches_call || matches_source { + if matches_source && name_is_sequelize_migration_entry(&summary.name) { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::Migration { version: None }, @@ -94,4 +89,19 @@ mod tests { assert_eq!(binding.adapter, "migration-sequelize"); assert!(matches!(binding.kind, EntryKind::Migration { .. })); } + + #[test] + fn skips_unrelated_helper_in_sequelize_migration_file() { + let src: &[u8] = b"module.exports = {\n async up(queryInterface, Sequelize) { await queryInterface.createTable('users', {}); },\n};\nfunction normalizeName(name) { return String(name); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "normalizeName".into(), + ..Default::default() + }; + assert!( + MigrationSequelizeAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/scheduled_celery.rs b/src/dynamic/framework/adapters/scheduled_celery.rs index 3cb4eb78..e1a1c11a 100644 --- a/src/dynamic/framework/adapters/scheduled_celery.rs +++ b/src/dynamic/framework/adapters/scheduled_celery.rs @@ -8,6 +8,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct ScheduledCeleryAdapter; @@ -53,6 +54,36 @@ fn extract_schedule(file_bytes: &[u8]) -> Option { None } +fn name_registered_as_celery_task(name: &str, file_bytes: &[u8]) -> bool { + if name.is_empty() { + return false; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let needle = format!("def {name}("); + let Some(def_idx) = text.find(&needle) else { + return false; + }; + let before = &text[..def_idx]; + let since_prev_def = before + .rfind("\ndef ") + .map(|idx| &before[idx + 1..]) + .unwrap_or(before); + since_prev_def.lines().any(|line| { + let trimmed = line.trim(); + trimmed.contains("@shared_task") + || trimmed.contains("@app.task") + || trimmed.contains("@celery.task") + }) +} + +fn typed_container_allows_celery(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("celery") || lc.contains("task") || lc.contains("signature") +} + impl FrameworkAdapter for ScheduledCeleryAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -65,31 +96,59 @@ impl FrameworkAdapter for ScheduledCeleryAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_celery); - let matches_source = source_imports_celery(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::ScheduledJob { - schedule: extract_schedule(file_bytes), - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_celery(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_celery(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_celery( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_celery(file_bytes) { + return None; + } + let registered = name_registered_as_celery_task(&summary.name, file_bytes); + let celery_call = super::any_callee_matches(summary, callee_is_celery) + && super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_celery, + typed_container_allows_celery, + ); + if !(registered || celery_call) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_python(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -114,4 +173,46 @@ mod tests { assert_eq!(binding.adapter, "scheduled-celery"); assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. })); } + + #[test] + fn skips_unregistered_helper_in_celery_file() { + let src: &[u8] = b"from celery import shared_task\n\ + @shared_task\n\ + def tick(payload):\n print(payload)\n\ + def format_payload(payload):\n return str(payload)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "format_payload".into(), + ..Default::default() + }; + assert!( + ScheduledCeleryAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_rejects_non_celery_delay_collision() { + let src: &[u8] = b"from celery import shared_task\n\ + def enqueue(payload):\n mailer.delay(payload)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "enqueue".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "mailer.delay".to_owned(), + receiver: Some("mailer".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Mailer".to_owned())); + assert!( + ScheduledCeleryAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/scheduled_quartz.rs b/src/dynamic/framework/adapters/scheduled_quartz.rs index d2388912..8a2a036c 100644 --- a/src/dynamic/framework/adapters/scheduled_quartz.rs +++ b/src/dynamic/framework/adapters/scheduled_quartz.rs @@ -7,6 +7,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct ScheduledQuartzAdapter; @@ -53,6 +54,46 @@ fn extract_schedule(file_bytes: &[u8]) -> Option { None } +fn name_is_quartz_entry(name: &str) -> bool { + name == "execute" +} + +fn name_annotated_as_scheduled(name: &str, file_bytes: &[u8]) -> bool { + if name.is_empty() { + return false; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + for needle in [ + format!("void {name}("), + format!("public void {name}("), + format!("private void {name}("), + format!("protected void {name}("), + ] { + if let Some(idx) = text.find(&needle) { + let before = &text[..idx]; + let since_prev_method = before + .rfind("\n ") + .map(|prev| &before[prev + 1..]) + .unwrap_or(before); + if since_prev_method.contains("@Scheduled") { + return true; + } + } + } + false +} + +fn typed_container_allows_quartz(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("quartz") + || lc.contains("scheduler") + || lc.contains("jobbuilder") + || lc.contains("triggerbuilder") +} + impl FrameworkAdapter for ScheduledQuartzAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -65,31 +106,60 @@ impl FrameworkAdapter for ScheduledQuartzAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_quartz); - let matches_source = source_imports_quartz(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::ScheduledJob { - schedule: extract_schedule(file_bytes), - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_quartz(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_quartz(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_quartz( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + if !source_imports_quartz(file_bytes) { + return None; + } + let job_entry = name_is_quartz_entry(&summary.name); + let scheduled_method = name_annotated_as_scheduled(&summary.name, file_bytes); + let quartz_call = super::any_callee_matches(summary, callee_is_quartz) + && super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_quartz, + typed_container_allows_quartz, + ); + if !(job_entry || scheduled_method || quartz_call) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_java(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -132,4 +202,49 @@ mod tests { assert_eq!(schedule.as_deref(), Some("0 0 12 * * ?")); } } + + #[test] + fn skips_unrelated_helper_in_quartz_file() { + let src: &[u8] = b"import org.quartz.Job;\n\ + public class TickJob implements Job {\n\ + public void execute(JobExecutionContext ctx) { }\n\ + public String format(String payload) { return payload; }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "format".into(), + ..Default::default() + }; + assert!( + ScheduledQuartzAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_rejects_non_quartz_schedule_collision() { + let src: &[u8] = b"import org.quartz.Job;\n\ + public class TickJob implements Job {\n\ + public void enqueue(Object payload) { queue.scheduleJob(payload); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "enqueue".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "queue.scheduleJob".to_owned(), + receiver: Some("queue".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "MailQueue".to_owned())); + assert!( + ScheduledQuartzAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/websocket_actioncable.rs b/src/dynamic/framework/adapters/websocket_actioncable.rs index 6c377b4b..4f4e3b65 100644 --- a/src/dynamic/framework/adapters/websocket_actioncable.rs +++ b/src/dynamic/framework/adapters/websocket_actioncable.rs @@ -13,14 +13,6 @@ pub struct WebsocketActionCableAdapter; const ADAPTER_NAME: &str = "websocket-actioncable"; -fn callee_is_actioncable(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "receive" | "subscribed" | "unsubscribed" | "transmit" | "broadcast" - ) -} - fn source_imports_actioncable(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"ApplicationCable::Channel", @@ -54,6 +46,10 @@ fn extract_path(file_bytes: &[u8]) -> String { "/cable".to_owned() } +fn name_is_actioncable_entry(name: &str) -> bool { + matches!(name, "receive" | "subscribed" | "unsubscribed") +} + impl FrameworkAdapter for WebsocketActionCableAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -69,9 +65,7 @@ impl FrameworkAdapter for WebsocketActionCableAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_actioncable); - let matches_source = source_imports_actioncable(file_bytes); - if matches_call || matches_source { + if source_imports_actioncable(file_bytes) && name_is_actioncable_entry(&summary.name) { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::WebSocket { @@ -115,4 +109,19 @@ mod tests { assert_eq!(path, "chat_room"); } } + + #[test] + fn skips_unrelated_helper_in_actioncable_file() { + let src: &[u8] = b"class ChatChannel < ApplicationCable::Channel\n def receive(data)\n end\n def normalize(data)\n data.to_s\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "normalize".into(), + ..Default::default() + }; + assert!( + WebsocketActionCableAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/websocket_channels.rs b/src/dynamic/framework/adapters/websocket_channels.rs index 6e08117d..094eb348 100644 --- a/src/dynamic/framework/adapters/websocket_channels.rs +++ b/src/dynamic/framework/adapters/websocket_channels.rs @@ -13,14 +13,6 @@ pub struct WebsocketChannelsAdapter; const ADAPTER_NAME: &str = "websocket-channels"; -fn callee_is_channels(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "receive" | "receive_json" | "connect" | "disconnect" | "send" | "send_json" - ) -} - fn source_imports_channels(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"channels.generic.websocket", @@ -49,6 +41,13 @@ fn extract_path(file_bytes: &[u8]) -> String { "/ws/".to_owned() } +fn name_is_channels_entry(name: &str) -> bool { + matches!( + name, + "receive" | "receive_json" | "connect" | "disconnect" | "websocket_receive" + ) +} + impl FrameworkAdapter for WebsocketChannelsAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -64,9 +63,7 @@ impl FrameworkAdapter for WebsocketChannelsAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_channels); - let matches_source = source_imports_channels(file_bytes); - if matches_call || matches_source { + if source_imports_channels(file_bytes) && name_is_channels_entry(&summary.name) { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::WebSocket { @@ -109,4 +106,21 @@ mod tests { assert_eq!(binding.adapter, "websocket-channels"); assert!(matches!(binding.kind, EntryKind::WebSocket { .. })); } + + #[test] + fn skips_unrelated_helper_in_channels_file() { + let src: &[u8] = b"from channels.generic.websocket import WebsocketConsumer\n\ + class ChatConsumer(WebsocketConsumer):\n def receive(self, text_data=None):\n pass\n\ + def normalize_frame(text_data):\n return str(text_data)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize_frame".into(), + ..Default::default() + }; + assert!( + WebsocketChannelsAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/src/dynamic/framework/adapters/websocket_socketio.rs b/src/dynamic/framework/adapters/websocket_socketio.rs index 1ea21d80..c6945ae9 100644 --- a/src/dynamic/framework/adapters/websocket_socketio.rs +++ b/src/dynamic/framework/adapters/websocket_socketio.rs @@ -13,14 +13,6 @@ pub struct WebsocketSocketIoAdapter; const ADAPTER_NAME: &str = "websocket-socketio"; -fn callee_is_socketio(name: &str) -> bool { - let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); - matches!( - last, - "on" | "emit" | "send" | "AsyncServer" | "Server" | "event" - ) -} - fn source_imports_socketio(file_bytes: &[u8]) -> bool { const NEEDLES: &[&[u8]] = &[ b"import socketio", @@ -49,6 +41,29 @@ fn extract_path(file_bytes: &[u8]) -> String { "/".to_owned() } +fn name_registered_as_socketio_event(name: &str, file_bytes: &[u8]) -> bool { + if name.is_empty() { + return false; + } + let text = match std::str::from_utf8(file_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let def_needle = format!("def {name}("); + let Some(def_idx) = text.find(&def_needle) else { + return false; + }; + let before = &text[..def_idx]; + let since_prev_def = before + .rfind("\ndef ") + .map(|idx| &before[idx + 1..]) + .unwrap_or(before); + since_prev_def.contains("@sio.event") + || since_prev_def.contains("@socketio.event") + || since_prev_def.contains(&format!("@sio.on('{name}'")) + || since_prev_def.contains(&format!("@sio.on(\"{name}\"")) +} + impl FrameworkAdapter for WebsocketSocketIoAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -64,9 +79,8 @@ impl FrameworkAdapter for WebsocketSocketIoAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_socketio); - let matches_source = source_imports_socketio(file_bytes); - if matches_call || matches_source { + let registered = name_registered_as_socketio_event(&summary.name, file_bytes); + if source_imports_socketio(file_bytes) && registered { Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::WebSocket { @@ -113,4 +127,23 @@ mod tests { assert_eq!(path, "message"); } } + + #[test] + fn skips_unrelated_helper_in_socketio_file() { + let src: &[u8] = b"import socketio\n\ + sio = socketio.Server()\n\ + @sio.on('message')\n\ + def message(sid, data):\n pass\n\ + def normalize(data):\n return str(data)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "normalize".into(), + ..Default::default() + }; + assert!( + WebsocketSocketIoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } } diff --git a/tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json b/tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json new file mode 100644 index 00000000..0c3cfded --- /dev/null +++ b/tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js b/tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js new file mode 100644 index 00000000..96cbf82b --- /dev/null +++ b/tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js @@ -0,0 +1,19 @@ +const { SQSClient } = require("@aws-sdk/client-sqs"); + +class MetricsPublisher { + send(event) { + return Promise.resolve({ ok: true, event }); + } +} + +const sqs = new SQSClient({}); +const metrics = new MetricsPublisher(); + +function handler(event) { + return metrics.send({ + type: "delivery_attempt", + requestId: event.requestId, + }); +} + +module.exports = { handler, sqs }; diff --git a/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py b/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py new file mode 100644 index 00000000..02382ffa --- /dev/null +++ b/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py @@ -0,0 +1,16 @@ +import boto3 + + +sqs = boto3.client("sqs") + + +class AuditCache: + def process_message(self, envelope): + return {"stored": True, "id": envelope.get("id")} + + +cache = AuditCache() + + +def handler(envelope): + return cache.process_message(envelope) diff --git a/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py b/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py new file mode 100644 index 00000000..394396a9 --- /dev/null +++ b/tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py @@ -0,0 +1,13 @@ +import pika + + +class ReportWorker: + def process(self, report): + return {"status": "queued", "report_id": report.get("id")} + + +worker = ReportWorker() + + +def process(report): + return worker.process(report) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json b/tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json new file mode 100644 index 00000000..0c3cfded --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json @@ -0,0 +1,16 @@ +{ + "required_findings": [], + "forbidden_findings": [ + { "id_prefix": "taint-unsanitised-flow" } + ], + "noise_budget": { + "max_total_findings": 0, + "max_high_findings": 0 + }, + "performance_expectations": { + "max_ms_no_index": 1000, + "max_ms_index_cold": 1500, + "max_ms_index_warm": 500, + "ci_mode": "lenient" + } +} diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go b/tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go new file mode 100644 index 00000000..c251dbbd --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go @@ -0,0 +1,14 @@ +package graph + +import "context" + +// import "github.com/99designs/gqlgen/graphql" +type queryResolver struct{} + +func (r *queryResolver) User(ctx context.Context, id string) (string, error) { + return id, nil +} + +func NormalizeID(id string) string { + return id +} diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java b/tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java new file mode 100644 index 00000000..f9f76ad2 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java @@ -0,0 +1,15 @@ +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +class TickJob implements Job { + public void execute(JobExecutionContext context) {} + + public void enqueue(Object payload) { + NotificationQueue queue = new NotificationQueue(); + queue.scheduleJob(payload); + } +} + +class NotificationQueue { + void scheduleJob(Object payload) {} +} diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java b/tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java new file mode 100644 index 00000000..35a5631a --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java @@ -0,0 +1,11 @@ +import org.springframework.web.servlet.HandlerInterceptor; + +class AuditInterceptor implements HandlerInterceptor { + public boolean preHandle(Object request, Object response, Object handler) { + return true; + } + + public String normalize(String payload) { + return payload; + } +} diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js b/tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js new file mode 100644 index 00000000..8b99031c --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js @@ -0,0 +1,11 @@ +const { nodeDefinitions } = require('graphql-relay'); + +function resolveNode(globalId) { + return globalId; +} + +function normalizeId(id) { + return String(id); +} + +module.exports = { resolveNode, normalizeId, nodeDefinitions }; diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js b/tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js new file mode 100644 index 00000000..35bed83c --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js @@ -0,0 +1,15 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('users', {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('users'); + }, +}; + +function normalizeName(name) { + return String(name); +} + +module.exports.normalizeName = normalizeName; diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php b/tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php new file mode 100644 index 00000000..5e0d9d23 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php @@ -0,0 +1,9 @@ +withMiddleware([]); + } +} diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py new file mode 100644 index 00000000..37845f99 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py @@ -0,0 +1,11 @@ +from alembic import op + +revision = "abc123def4" + + +def upgrade(): + op.create_table("users") + + +def normalize_name(name): + return str(name) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py new file mode 100644 index 00000000..7d6a7951 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py @@ -0,0 +1,16 @@ +from celery import shared_task + + +@shared_task +def tick(payload): + return payload + + +class Mailer: + def delay(self, payload): + return payload + + +def enqueue(payload): + mailer = Mailer() + return mailer.delay(payload) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py new file mode 100644 index 00000000..02e84c74 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py @@ -0,0 +1,10 @@ +from channels.generic.websocket import WebsocketConsumer + + +class ChatConsumer(WebsocketConsumer): + def receive(self, text_data=None, bytes_data=None): + return text_data + + +def normalize_frame(text_data): + return str(text_data) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py new file mode 100644 index 00000000..e1d1fca5 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py @@ -0,0 +1,10 @@ +from django.utils.deprecation import MiddlewareMixin + + +class AuditMiddleware(MiddlewareMixin): + def process_request(self, request): + return None + + +def normalize_request(request): + return request diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py new file mode 100644 index 00000000..99e56d72 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + operations = [ + migrations.CreateModel(name="User", fields=[]), + ] + + +def normalize_name(name): + return str(name) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py new file mode 100644 index 00000000..19553218 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py @@ -0,0 +1,12 @@ +import graphene + + +class Query(graphene.ObjectType): + user = graphene.String() + + def resolve_user(self, info, id): + return id + + +def normalize_id(raw): + return str(raw) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py new file mode 100644 index 00000000..81939507 --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py @@ -0,0 +1,12 @@ +import socketio + +sio = socketio.Server() + + +@sio.on("message") +def message(sid, data): + return data + + +def normalize(data): + return str(data) diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb b/tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb new file mode 100644 index 00000000..a68df34c --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb @@ -0,0 +1,13 @@ +class ChatChannel < ApplicationCable::Channel + def subscribed + stream_from "chat_room" + end + + def receive(data) + data + end + + def normalize(data) + data.to_s + end +end diff --git a/tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs b/tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs new file mode 100644 index 00000000..e81525ae --- /dev/null +++ b/tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs @@ -0,0 +1,14 @@ +use juniper::graphql_object; + +pub struct Query; + +#[graphql_object] +impl Query { + fn user(&self, id: String) -> String { + id + } +} + +pub fn normalize_id(id: &str) -> String { + id.to_string() +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 848682d4..d1f5bb11 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -926,6 +926,27 @@ fn fp_guard_framework_express_res_json() { validate_expectations(&diags, &dir); } +/// FP guard, broker-adapter receiver collisions: OSS-shaped handlers named +/// `handler` / `process` and a non-SQS `.send(...)` publisher must stay +/// ordinary helper code unless receiver facts prove the call is on a broker +/// runtime object. +#[test] +fn fp_guard_broker_adapter_receiver_collisions() { + let dir = fixture_path("fp_guards/broker_adapter_collisions"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + +/// FP guard, Phase 21 adapter collisions: framework-marked files can contain +/// ordinary helpers, controller bootstrappers, mailer queues, and migration +/// formatting functions that must not be promoted to dynamic entry kinds. +#[test] +fn fp_guard_phase21_adapter_collisions() { + let dir = fixture_path("fp_guards/phase21_adapter_collisions"); + let diags = scan_fixture_dir(&dir, AnalysisMode::Full); + validate_expectations(&diags, &dir); +} + /// FP guard, FastAPI `dependencies=[Depends(requires_access_*)]` /// route-level guard short-circuits `auth_check_covers_subject` so /// the handler body's path-param ORM calls and row-variable method diff --git a/tests/message_handler_corpus.rs b/tests/message_handler_corpus.rs index 5e64f925..c06c7b5d 100644 --- a/tests/message_handler_corpus.rs +++ b/tests/message_handler_corpus.rs @@ -17,11 +17,15 @@ mod common; use nyx_scanner::dynamic::framework::registry::adapters_for; -use nyx_scanner::dynamic::framework::{FrameworkBinding, detect_binding}; +use nyx_scanner::dynamic::framework::{ + FrameworkBinding, detect_binding, detect_binding_with_context, +}; use nyx_scanner::dynamic::lang; use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use nyx_scanner::labels::Cap; +use nyx_scanner::summary::CalleeSite; use nyx_scanner::summary::FuncSummary; +use nyx_scanner::summary::ssa_summary::SsaFuncSummary; use nyx_scanner::symbol::Lang; const SUPPORTED_LANGS: &[Lang] = &[ @@ -215,6 +219,39 @@ fn detect_from_bytes(lang: Lang, bytes: &[u8], handler: &str) -> Option Option { + let bytes = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/fp_guards/broker_adapter_collisions") + .join(fixture), + ) + .expect("collision fixture exists"); + let ts_lang = ts_language_for(lang); + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&ts_lang).unwrap(); + let tree = parser.parse(&bytes, None).unwrap(); + let mut summary = FuncSummary { + name: handler.into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: callee.to_owned(), + receiver: Some(receiver.to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, receiver_ty.to_owned())); + detect_binding_with_context(&summary, Some(&ssa), tree.root_node(), &bytes, lang) +} + fn middleware_names(binding: &FrameworkBinding) -> Vec { binding .middleware @@ -417,6 +454,51 @@ def on_message(ch, method, properties, body):\n validate_request(body)\n", } } +#[test] +fn phase20_broker_adapter_receiver_collisions_have_fixture_anchors() { + let cases: &[(Lang, &str, &str, &str, &str, &str)] = &[ + ( + Lang::Python, + "python_non_broker_handler.py", + "handler", + "cache.process_message", + "cache", + "AuditCache", + ), + ( + Lang::Python, + "python_non_rabbit_process.py", + "process", + "worker.process", + "worker", + "ReportWorker", + ), + ( + Lang::JavaScript, + "node_non_sqs_send.js", + "handler", + "metrics.send", + "metrics", + "MetricsPublisher", + ), + ]; + + for (lang, fixture, handler, callee, receiver, receiver_ty) in cases { + let binding = detect_collision_fixture_with_receiver( + *lang, + fixture, + handler, + callee, + receiver, + receiver_ty, + ); + assert!( + binding.is_none(), + "{fixture} should not bind as a broker message handler; got {binding:?}", + ); + } +} + #[test] fn registry_slices_include_phase_20_adapters() { let java_names: Vec<&'static str> = adapters_for(Lang::Java).iter().map(|a| a.name()).collect(); diff --git a/tests/phase21_corpus.rs b/tests/phase21_corpus.rs index 492a2629..c78ed0f9 100644 --- a/tests/phase21_corpus.rs +++ b/tests/phase21_corpus.rs @@ -26,7 +26,8 @@ use nyx_scanner::dynamic::lang; use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use nyx_scanner::evidence::EntryKind as EvEntryKind; use nyx_scanner::labels::Cap; -use nyx_scanner::summary::FuncSummary; +use nyx_scanner::summary::ssa_summary::SsaFuncSummary; +use nyx_scanner::summary::{CalleeSite, FuncSummary}; use nyx_scanner::symbol::Lang; fn make_spec(lang: Lang, kind: EvEntryKind, entry_name: &str, entry_file: &str) -> HarnessSpec { @@ -91,6 +92,46 @@ fn run_adapter( .unwrap_or_else(|| panic!("{} did not fire on {fixture}", adapter.name())) } +fn detect_phase21_fp_fixture( + adapter: &dyn FrameworkAdapter, + lang: Lang, + handler: &str, + fixture: &str, + typed_call: Option<(&str, &str, &str)>, +) -> Option { + let bytes = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/fp_guards/phase21_adapter_collisions") + .join(fixture), + ) + .unwrap_or_else(|e| panic!("read Phase 21 FP fixture {fixture}: {e}")); + let tree = parse(lang, &bytes); + let mut summary = FuncSummary { + name: handler.into(), + ..Default::default() + }; + let mut ssa = SsaFuncSummary::default(); + if let Some((callee, receiver, receiver_ty)) = typed_call { + summary.callees.push(CalleeSite { + name: callee.to_owned(), + receiver: Some(receiver.to_owned()), + ordinal: 0, + ..Default::default() + }); + ssa.typed_call_receivers.push((0, receiver_ty.to_owned())); + } + let ssa_ref = typed_call.is_some().then_some(&ssa); + adapter.detect_with_context(&summary, ssa_ref, tree.root_node(), &bytes) +} + +struct Phase21FpCase<'a> { + adapter: &'a dyn FrameworkAdapter, + lang: Lang, + handler: &'a str, + fixture: &'a str, + typed_call: Option<(&'a str, &'a str, &'a str)>, +} + // ── Supported-set assertions ────────────────────────────────────────────────── #[test] @@ -447,6 +488,134 @@ fn migration_prisma_adapter_binds_vuln_fixture() { assert_eq!(b.adapter, "migration-prisma"); } +#[test] +fn phase21_adapter_collision_fixtures_do_not_bind() { + let cases = [ + Phase21FpCase { + adapter: &ScheduledCeleryAdapter, + lang: Lang::Python, + handler: "enqueue", + fixture: "python_celery_mailer_delay.py", + typed_call: Some(("mailer.delay", "mailer", "Mailer")), + }, + Phase21FpCase { + adapter: &ScheduledQuartzAdapter, + lang: Lang::Java, + handler: "enqueue", + fixture: "java_quartz_queue_schedule.java", + typed_call: Some(("queue.scheduleJob", "queue", "NotificationQueue")), + }, + Phase21FpCase { + adapter: &GraphqlGrapheneAdapter, + lang: Lang::Python, + handler: "normalize_id", + fixture: "python_graphene_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &GraphqlGqlgenAdapter, + lang: Lang::Go, + handler: "NormalizeID", + fixture: "go_gqlgen_helper.go", + typed_call: None, + }, + Phase21FpCase { + adapter: &GraphqlJuniperAdapter, + lang: Lang::Rust, + handler: "normalize_id", + fixture: "rust_juniper_helper.rs", + typed_call: None, + }, + Phase21FpCase { + adapter: &GraphqlRelayAdapter, + lang: Lang::JavaScript, + handler: "normalizeId", + fixture: "js_relay_helper.js", + typed_call: None, + }, + Phase21FpCase { + adapter: &WebsocketSocketIoAdapter, + lang: Lang::Python, + handler: "normalize", + fixture: "python_socketio_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &WebsocketChannelsAdapter, + lang: Lang::Python, + handler: "normalize_frame", + fixture: "python_channels_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &WebsocketActionCableAdapter, + lang: Lang::Ruby, + handler: "normalize", + fixture: "ruby_actioncable_helper.rb", + typed_call: None, + }, + Phase21FpCase { + adapter: &MiddlewareDjangoAdapter, + lang: Lang::Python, + handler: "normalize_request", + fixture: "python_django_middleware_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &MiddlewareLaravelAdapter, + lang: Lang::Php, + handler: "configure", + fixture: "php_laravel_bootstrapper.php", + typed_call: Some(("app.withMiddleware", "app", "ApplicationBuilder")), + }, + Phase21FpCase { + adapter: &MiddlewareSpringAdapter, + lang: Lang::Java, + handler: "normalize", + fixture: "java_spring_middleware_helper.java", + typed_call: None, + }, + Phase21FpCase { + adapter: &MigrationDjangoAdapter, + lang: Lang::Python, + handler: "normalize_name", + fixture: "python_django_migration_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &MigrationFlaskAdapter, + lang: Lang::Python, + handler: "normalize_name", + fixture: "python_alembic_helper.py", + typed_call: None, + }, + Phase21FpCase { + adapter: &MigrationSequelizeAdapter, + lang: Lang::JavaScript, + handler: "normalizeName", + fixture: "js_sequelize_helper.js", + typed_call: None, + }, + ]; + + for case in cases { + let binding = detect_phase21_fp_fixture( + case.adapter, + case.lang, + case.handler, + case.fixture, + case.typed_call, + ); + assert!( + binding.is_none(), + "{fixture}::{handler} should not bind through {}; got {binding:?}", + case.adapter.name(), + fixture = case.fixture, + handler = case.handler, + ); + } +} + // ── Harness emit shape ──────────────────────────────────────────────────────── #[test]