refactor(dynamic): enhance resolver detection for frameworks, refine SSA receiver validation, and expand test coverage

This commit is contained in:
elipeter 2026-05-24 14:34:39 -05:00
parent 3027c1afa7
commit f49211d788
38 changed files with 1198 additions and 137 deletions

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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()
);
}
}

View file

@ -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"<?php\nclass Bootstrapper {\n public function configure($app) { return $app->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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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<FrameworkBinding> {
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()
);
}
}

View file

@ -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"
}
}

View file

@ -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 };

View file

@ -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)

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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) {}
}

View file

@ -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;
}
}

View file

@ -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 };

View file

@ -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;

View file

@ -0,0 +1,9 @@
<?php
class Bootstrapper
{
public function configure($app)
{
return $app->withMiddleware([]);
}
}

View file

@ -0,0 +1,11 @@
from alembic import op
revision = "abc123def4"
def upgrade():
op.create_table("users")
def normalize_name(name):
return str(name)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,12 @@
import socketio
sio = socketio.Server()
@sio.on("message")
def message(sid, data):
return data
def normalize(data):
return str(data)

View file

@ -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

View file

@ -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()
}

View file

@ -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

View file

@ -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<Framewor
detect_binding(&summary, tree.root_node(), bytes, lang)
}
fn detect_collision_fixture_with_receiver(
lang: Lang,
fixture: &str,
handler: &str,
callee: &str,
receiver: &str,
receiver_ty: &str,
) -> Option<FrameworkBinding> {
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<String> {
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();

View file

@ -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<FrameworkBinding> {
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]