Performance and precision pass (#64)

This commit is contained in:
Eli Peter 2026-05-04 19:58:04 -04:00 committed by GitHub
parent c7c5e0f3a1
commit fb698d2c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 9932 additions and 517 deletions

View file

@ -4,8 +4,7 @@ use super::axum::{
expanded_guard_call_sites, guard_calls_for_handler, inject_guard_checks, rust_param_aliases,
};
use super::common::{
attach_route_handler, call_name, collect_top_level_units, named_children, resolve_handler_node,
string_literal_value,
attach_route_handler, call_name, named_children, resolve_handler_node, string_literal_value,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{
@ -30,21 +29,11 @@ impl AuthExtractor for ActixWebExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
collect_routes(root, root, bytes, path, rules, &mut model);
apply_typed_extractor_guards_to_units(
root,
bytes,
rules,
&mut model,
GuardFramework::ActixWeb,
);
model
collect_routes(root, root, bytes, path, rules, model);
apply_typed_extractor_guards_to_units(root, bytes, rules, model, GuardFramework::ActixWeb);
}
}

View file

@ -1,8 +1,7 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_name, call_site_from_node, call_sites_from_value,
collect_top_level_units, function_definition_node, named_children, resolve_handler_node,
string_literal_value, text,
function_definition_node, named_children, resolve_handler_node, string_literal_value, text,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{
@ -29,15 +28,11 @@ impl AuthExtractor for AxumExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
collect_routes(root, root, bytes, path, rules, &mut model);
apply_typed_extractor_guards_to_units(root, bytes, rules, &mut model, GuardFramework::Axum);
model
collect_routes(root, root, bytes, path, rules, model);
apply_typed_extractor_guards_to_units(root, bytes, rules, model, GuardFramework::Axum);
}
}

View file

@ -896,6 +896,13 @@ fn collect_unit_state(
// `instance_variable`.
if matches!(node.kind(), "assignment" | "assignment_expression") {
collect_row_population(node, bytes, state);
// Python `verified_ids = set()` /
// `cache: dict[str,int] = {}` and JS analogues bind a
// local non-sink container. `collect_non_sink_binding`
// accepts both `pattern`/`value` and `left`/`right`
// field names so the same recognition path covers
// these assignment-node shapes.
collect_non_sink_binding(node, bytes, rules, state);
}
}
"for_expression" => {
@ -915,9 +922,27 @@ fn collect_unit_state(
_ => {}
}
for value in extract_value_refs(node, bytes) {
state.value_refs.push(value);
}
// O(1) per-node shallow value-ref emission, then descend.
//
// Pre-fix this site called `extract_value_refs(node, bytes)` which walks
// node's entire subtree. Combined with the recursion below — which
// visits every descendant and re-runs the same call at each level — the
// total work was O(N * subtree_size) ≈ O(N²) per function body. On
// mm/channels/app the inner-walk dominated `build_function_unit_with_meta`
// and its descendants (~17%+15%+11% of total wall-clock split across
// `build_function_unit_with_meta`, `collect_unit_state`, and
// `extract_value_refs` in the post-shared-model profile, 2026-05-04).
//
// The recursion below already visits every descendant once. Emitting a
// shallow value-ref per node — only the ref the node itself represents —
// produces the same SET of value-refs after `dedup_value_refs` runs in
// `build_function_unit_with_meta`, because every ref-emitting kind
// (member chain, subscript, accessor call, identifier) is reachable as a
// single node visit. Public callers of `extract_value_refs` (e.g.
// `collect_call`, `collect_condition`, assignment-side extraction) keep
// the deep walk: they intentionally want refs from the full subtree
// rooted at the argument they pass.
append_shallow_value_ref(node, bytes, &mut state.value_refs);
for idx in 0..node.named_child_count() {
let Some(child) = node.named_child(idx as u32) else {
@ -927,6 +952,57 @@ fn collect_unit_state(
}
}
/// Per-node value-ref emission used inside `collect_unit_state`'s tree walk.
///
/// Returns the value-ref the node itself represents (a member chain, a
/// subscript, an accessor call's chain, or an identifier-like leaf), without
/// descending into descendants. The caller's existing AST recursion handles
/// children; relying on that recursion turns the previously O(N²) per-body
/// walk into O(N).
fn append_shallow_value_ref(node: Node<'_>, bytes: &[u8], refs: &mut Vec<ValueRef>) {
match node.kind() {
"member_expression"
| "attribute"
| "selector_expression"
| "field_expression"
| "field_access" => {
if let Some(value) = member_value_ref(node, bytes) {
refs.push(value);
}
}
"subscript_expression" | "subscript" | "element_reference" | "index_expression" => {
if let Some(value) = subscript_value_ref(node, bytes) {
refs.push(value);
}
}
"call_expression" | "call" | "method_invocation" | "method_call_expression" => {
// Accessor-call chains (`cache.get(key)`, `req.params.id`) absorb
// into a single chain ValueRef; non-accessor calls return None
// here and rely on recursion to visit `function` + arg children
// so each leaf identifier emits its own ref.
if let Some(value) = call_value_ref(node, bytes) {
refs.push(value);
}
}
// Bare identifier and Ruby `@foo` / `@@foo` / `$foo` leaves: emit a
// single Identifier-kind ValueRef. Mirrors `extract_value_refs`'s
// identifier arm so `dedup_value_refs` collapses any cross-path
// duplicates against existing emissions from sibling deep walks
// (e.g. `collect_condition`'s `extract_value_refs(condition)`).
"identifier" | "instance_variable" | "class_variable" | "global_variable" => {
refs.push(ValueRef {
source_kind: ValueSourceKind::Identifier,
name: text(node, bytes),
base: None,
field: None,
index: None,
span: span(node),
});
}
_ => {}
}
}
fn collect_call(node: Node<'_>, bytes: &[u8], rules: &AuthAnalysisRules, state: &mut UnitState) {
let callee = call_name(node, bytes);
if callee.is_empty() {
@ -1059,22 +1135,28 @@ fn collect_condition(
}
}
/// Detect `let` bindings that produce a known non-sink collection
/// (e.g. `HashMap::new()`, `Vec::with_capacity(_)`, `vec![]`, or an
/// explicit type annotation like `: HashMap<_, _>`). Registered
/// variable names are consulted by `collect_call` so later method
/// calls on those bindings (`map.insert(..)`, `set.remove(..)`)
/// aren't treated as auth-relevant Read/Mutation operations.
/// Detect bindings that produce a known non-sink collection
/// (e.g. `HashMap::new()`, `Vec::with_capacity(_)`, `vec![]`, an
/// explicit type annotation like `: HashMap<_, _>`, or Python's
/// bare `set()` / `dict()` / `collections.defaultdict(list)`).
/// Registered variable names are consulted by `collect_call` so
/// later method calls on those bindings (`map.insert(..)`,
/// `set.remove(..)`, `verified_ids.update(..)`) aren't treated as
/// auth-relevant Read/Mutation operations.
///
/// Rust-oriented in practice; JS/TS/Python/etc. use different
/// declaration node kinds and are unaffected.
/// Field names accepted: Rust `let_declaration` uses `pattern` /
/// `value`; Python `assignment` and JS `assignment_expression` use
/// `left` / `right`. Both shapes share the same recognition path.
fn collect_non_sink_binding(
node: Node<'_>,
bytes: &[u8],
rules: &AuthAnalysisRules,
state: &mut UnitState,
) {
let Some(pattern) = node.child_by_field_name("pattern") else {
let Some(pattern) = node
.child_by_field_name("pattern")
.or_else(|| node.child_by_field_name("left"))
else {
return;
};
let Some(var_name) = first_identifier_name(pattern, bytes) else {
@ -1092,7 +1174,9 @@ fn collect_non_sink_binding(
}
}
if let Some(value) = node.child_by_field_name("value")
if let Some(value) = node
.child_by_field_name("value")
.or_else(|| node.child_by_field_name("right"))
&& value_is_non_sink_constructor(value, bytes, rules)
{
state.non_sink_vars.insert(var_name);
@ -3457,18 +3541,53 @@ fn collect_param_names(
"parameter_declaration" | "variadic_parameter_declaration"
if node.child_by_field_name("name").is_some() =>
{
if let Some(type_node) = node.child_by_field_name("type")
&& is_go_non_user_input_type(type_node, bytes)
let type_node = node.child_by_field_name("type");
if let Some(t) = type_node
&& is_go_non_user_input_type(t, bytes)
{
return;
}
// Mirror of the Python `typed_parameter` filter (see
// `is_python_id_like_typed_param` arm above): for non-route
// units, an id-like Go param whose declared type is a
// bounded primitive scalar (`int64`, `uint32`, `string`,
// `bool`, `byte`, `rune`, `float64`, …) is a caller-passed
// scope identifier, not user-controlled HTTP input. Real
// Go HTTP handlers always carry a framework-request-typed
// param (`*http.Request`, `*gin.Context`, `echo.Context`,
// `*fiber.Ctx`, `*context.APIContext`, …) and are
// recognised by the per-framework route extractors which
// call `function_params_route_handler`
// (`include_id_like_typed = true`) — those bypass this
// filter so id-shaped path params survive on real routes.
//
// Real-repo trigger: `/Users/elipeter/oss/gitea` ─ ~957
// `go.auth.missing_ownership_check` findings on backend
// helpers like
// `func GetRunByRepoAndID(ctx context.Context,
// repoID, runID int64)`,
// `func DeleteRunner(ctx context.Context, id int64)`,
// and the entire `models/...` DAO layer where the
// ownership check sits in the calling route handler.
// Same shape over-fires on minio's `cmd/iam-*-store`
// helpers and would on every Go ORM/DAO codebase.
let type_is_bounded_scalar = type_node
.map(|t| is_go_bounded_scalar_type(t, bytes))
.unwrap_or(false);
let mut cursor = node.walk();
for child in node.children_by_field_name("name", &mut cursor) {
if child.kind() == "identifier" {
let name = text(child, bytes);
if !name.is_empty() && !out.contains(&name) {
out.push(name);
if name.is_empty() || out.contains(&name) {
continue;
}
if !include_id_like_typed
&& type_is_bounded_scalar
&& is_go_id_like_typed_param(&name)
{
continue;
}
out.push(name);
}
}
}
@ -3635,6 +3754,56 @@ fn is_python_id_like_typed_param(name: &str) -> bool {
lower == "id" || lower.ends_with("id") || lower.ends_with("_id") || lower.ends_with("ids")
}
/// Same shape predicate used by the Go typed-param fallback in
/// `collect_param_names`. Kept separate from the Python helper so the
/// two recognisers can diverge if/when language-specific spellings
/// emerge; the current vocabulary is the same canonical id-suffix
/// set as `auth_analysis::checks::is_id_like_name`.
fn is_go_id_like_typed_param(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower == "id" || lower.ends_with("id") || lower.ends_with("_id") || lower.ends_with("ids")
}
/// True iff `type_node` names a Go bounded primitive scalar:
/// integer (`int*` / `uint*` / `byte` / `rune` / `uintptr`), floating
/// point (`float32` / `float64`), `bool`, or `string`. Used by the
/// Go arm of `collect_param_names` to recognise the
/// "id-like name + scalar type" DAO-helper shape and refuse to lift
/// such params into `unit.params` for non-route units.
///
/// Conservative scope: only bare `type_identifier` matches. Pointer
/// types (`*Foo`), generic types (`Map[K, V]`), qualified types
/// (`pkg.Type`), and slice/array types (`[]T`) are framework or
/// payload shapes, NOT bounded primitives, so they're left alone and
/// the param keeps its name. This keeps real handler shapes that
/// happen to spell an id-like name on a complex type (`req
/// *RequestWithID`) from being silently dropped.
fn is_go_bounded_scalar_type(type_node: Node<'_>, bytes: &[u8]) -> bool {
if type_node.kind() != "type_identifier" {
return false;
}
matches!(
text(type_node, bytes).as_str(),
"int"
| "int8"
| "int16"
| "int32"
| "int64"
| "uint"
| "uint8"
| "uint16"
| "uint32"
| "uint64"
| "uintptr"
| "byte"
| "rune"
| "float32"
| "float64"
| "bool"
| "string"
)
}
pub fn is_function_like(node: Node<'_>) -> bool {
matches!(
node.kind(),
@ -4080,20 +4249,41 @@ fn subscript_value_ref(node: Node<'_>, bytes: &[u8]) -> Option<ValueRef> {
pub fn member_chain(node: Node<'_>, bytes: &[u8]) -> Vec<String> {
if node.kind() == "call" {
let mut chain = if let Some(receiver) = node.child_by_field_name("receiver") {
member_chain(receiver, bytes)
} else {
Vec::new()
};
// Ruby-style call: explicit receiver field + method/name field.
if let Some(receiver) = node.child_by_field_name("receiver") {
let mut chain = member_chain(receiver, bytes);
let method = node
.child_by_field_name("method")
.or_else(|| node.child_by_field_name("name"))
.map(|method| text(method, bytes))
.unwrap_or_default();
if !method.is_empty() {
chain.push(method);
}
return chain;
}
// Python-style call: callable expression in the `function` field.
// Recursing into it lets chained shapes like
// `select(X).filter_by(...)` produce `["select()", "filter_by"]`
// — the parent attribute branch appends `()` when its `object`
// is a call, marking the intermediate-call shape so that
// `receiver_is_chained_call` detects it. Closes airflow-style
// SQLAlchemy queryset-builder chains that previously reduced to
// bare `["filter_by"]`.
if let Some(function) = node.child_by_field_name("function") {
return member_chain(function, bytes);
}
// Bare-method fallback for parser shapes that expose method/name
// without a receiver (Ruby implicit-self calls, etc.).
let method = node
.child_by_field_name("method")
.or_else(|| node.child_by_field_name("name"))
.map(|method| text(method, bytes))
.unwrap_or_default();
if !method.is_empty() {
chain.push(method);
return vec![method];
}
return chain;
return Vec::new();
}
if node.kind() == "method_invocation" || node.kind() == "method_call_expression" {
@ -4164,7 +4354,23 @@ pub fn member_chain(node: Node<'_>, bytes: &[u8]) -> Vec<String> {
.or_else(|| node.child_by_field_name("operand"))
.or_else(|| node.child_by_field_name("argument"))
{
chain.extend(member_chain(object, bytes));
let object_is_call = matches!(
object.kind(),
"call" | "call_expression" | "method_invocation" | "method_call_expression"
);
let mut sub = member_chain(object, bytes);
// Mark intermediate-call segments with `()` so a downstream
// chain like `select(X).filter_by(...)` becomes
// `["select()", "filter_by"]` rather than `["select", "filter_by"]`.
// `receiver_is_chained_call` consults the `(` to detect the
// opaque-builder receiver.
if object_is_call
&& sub.last().map(|s| !s.ends_with(')')).unwrap_or(false)
&& let Some(last) = sub.last_mut()
{
last.push_str("()");
}
chain.extend(sub);
}
if let Some(property) = node
.child_by_field_name("property")
@ -4876,6 +5082,200 @@ mod tests {
assert!(!params.contains(&"int".to_string()), "got {:?}", params);
}
/// DAO-helper shape (`func GetRunByRepoAndID(ctx context.Context,
/// repoID, runID int64)`): id-like names with bounded primitive
/// scalar types are caller-passed scope identifiers, NOT user
/// input. For non-route units (`function_params`,
/// `include_id_like_typed = false`), they must NOT lift into
/// `unit.params` — that would gate `unit_has_user_input_evidence`
/// open on every internal Go ORM helper and over-fire
/// `go.auth.missing_ownership_check`.
///
/// Real-repo trigger:
/// `/Users/elipeter/oss/gitea/models/actions/run_job.go::
/// GetRunByRepoAndID` and ~957 sibling helpers across gitea's
/// `models/...` DAO layer. Same shape over-fires on minio's
/// `cmd/iam-*-store` and is the canonical Go ORM helper signature.
#[test]
fn collect_param_names_go_drops_id_like_scalar_params_for_dao_helper() {
use super::function_params;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_go::LANGUAGE))
.unwrap();
let src =
b"package x\nfunc GetRunByRepoAndID(ctx context.Context, repoID, runID int64) {}\n";
let tree = parser.parse(src.as_slice(), None).unwrap();
let func = (0..tree.root_node().named_child_count())
.filter_map(|i| tree.root_node().named_child(i as u32))
.find(|n| n.kind() == "function_declaration")
.expect("file should have a function_declaration");
let params = function_params(func, src);
assert!(
!params.contains(&"ctx".to_string()),
"context.Context dropped: got {:?}",
params
);
assert!(
!params.contains(&"repoID".to_string()),
"id-like scalar param dropped for DAO helper: got {:?}",
params
);
assert!(
!params.contains(&"runID".to_string()),
"id-like scalar param dropped for DAO helper: got {:?}",
params
);
assert!(
params.is_empty(),
"no params survive on DAO-shape helper: got {:?}",
params
);
}
/// Conservative scope: only **bounded primitive scalar** types
/// trigger the id-like drop. Pointer / struct / slice types are
/// payload shapes that may or may not be user-controlled — leave
/// them alone so non-DAO helpers retain their evidence.
#[test]
fn collect_param_names_go_keeps_id_like_pointer_struct_param() {
use super::function_params;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_go::LANGUAGE))
.unwrap();
// `runnerID *Runner` — id-like name, but the type is a pointer
// (payload shape), so the param name must survive.
let src = b"package x\nfunc UpdateRunner(ctx context.Context, runnerID *Runner) {}\n";
let tree = parser.parse(src.as_slice(), None).unwrap();
let func = (0..tree.root_node().named_child_count())
.filter_map(|i| tree.root_node().named_child(i as u32))
.find(|n| n.kind() == "function_declaration")
.expect("file should have a function_declaration");
let params = function_params(func, src);
assert!(
params.contains(&"runnerID".to_string()),
"id-like pointer param survives: got {:?}",
params
);
}
/// Route handlers go through `function_params_route_handler`
/// (`include_id_like_typed = true`) — the id-like-scalar filter
/// must NOT trip there. Path-param-on-REST-route is *the*
/// primary user input and middleware-injected auth checks rely on
/// these names being present in `unit.params`.
#[test]
fn collect_param_names_go_route_handler_keeps_id_like_scalar_params() {
use super::function_params_route_handler;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_go::LANGUAGE))
.unwrap();
let src = b"package x\nfunc GetRepo(ctx context.Context, repoID int64) {}\n";
let tree = parser.parse(src.as_slice(), None).unwrap();
let func = (0..tree.root_node().named_child_count())
.filter_map(|i| tree.root_node().named_child(i as u32))
.find(|n| n.kind() == "function_declaration")
.expect("file should have a function_declaration");
let params = function_params_route_handler(func, src);
assert!(
params.contains(&"repoID".to_string()),
"id-like scalar param kept for route handler: got {:?}",
params
);
}
/// Pin `member_chain` output for the SQLAlchemy queryset chain
/// `select(C).filter_by(id=x)`. Pre-fix, Python `call` nodes use a
/// `function` field (not `receiver`/`method`) so the recursive call
/// arm returned an empty Vec, reducing the chain to bare
/// `["filter_by"]`. The fix: (1) traverse `function` field in the
/// `call` arm; (2) the parent attribute branch appends `()` to last
/// segment when its `object` is a call. Together they produce
/// `["select()", "filter_by"]` so `receiver_is_chained_call` detects
/// the intermediate-call shape.
#[test]
fn member_chain_python_select_filter_by_chain_marks_intermediate_call() {
use super::{callee_name, member_chain};
use tree_sitter::{Node, Parser};
let mut parser = Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_python::LANGUAGE))
.unwrap();
let src = b"x = select(C).filter_by(id=u)\n";
let tree = parser.parse(src.as_slice(), None).unwrap();
fn find_outer_call<'a>(node: Node<'a>) -> Option<Node<'a>> {
if node.kind() == "call"
&& let Some(function) = node.child_by_field_name("function")
&& function.kind() == "attribute"
{
return Some(node);
}
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i as u32)
&& let Some(found) = find_outer_call(child)
{
return Some(found);
}
}
None
}
let outer_call = find_outer_call(tree.root_node())
.expect("expected outer call node `select(C).filter_by(id=u)`");
assert_eq!(
member_chain(outer_call, src),
vec!["select()".to_string(), "filter_by".to_string()],
"Python chained call must produce `[select(), filter_by]` so receiver_is_chained_call detects the intermediate-call shape",
);
assert_eq!(
callee_name(outer_call, src),
"select().filter_by".to_string(),
"callee_name joins the chain with `.`",
);
}
/// Regression guard: simple Python `obj.method(arg)` callees keep
/// their previous `member_chain` output (`["obj", "method"]`). The
/// `function`-field traversal must not pollute non-chained shapes.
#[test]
fn member_chain_python_simple_attribute_call_unchanged() {
use super::callee_name;
use tree_sitter::{Node, Parser};
let mut parser = Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_python::LANGUAGE))
.unwrap();
let src = b"x = obj.method(a)\n";
let tree = parser.parse(src.as_slice(), None).unwrap();
fn find_call<'a>(node: Node<'a>) -> Option<Node<'a>> {
if node.kind() == "call" {
return Some(node);
}
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i as u32)
&& let Some(found) = find_call(child)
{
return Some(found);
}
}
None
}
let call_node = find_call(tree.root_node()).expect("expected `obj.method(a)` call");
assert_eq!(
callee_name(call_node, src),
"obj.method".to_string(),
"simple attribute call must not pick up `()` markers",
);
}
mod ruby_visibility_and_callbacks {
use super::super::{
RubyVisibility, ruby_callback_target_names, ruby_method_is_callback_or_private,

View file

@ -5,7 +5,7 @@ use super::common::{
string_literal_value, text, visit_named_nodes,
};
use crate::auth_analysis::config::{AuthAnalysisRules, matches_name};
use crate::auth_analysis::extract::common::{attach_route_handler, collect_top_level_units};
use crate::auth_analysis::extract::common::attach_route_handler;
use crate::auth_analysis::model::{
AnalysisUnitKind, AuthorizationModel, CallSite, Framework, HttpMethod,
};
@ -29,18 +29,14 @@ impl AuthExtractor for DjangoExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| {
if node.kind() == "call" {
maybe_collect_django_path(root, node, bytes, path, rules, &mut model);
maybe_collect_django_path(root, node, bytes, path, rules, model);
}
});
model
}
}

View file

@ -1,8 +1,8 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_site_from_node, collect_top_level_units, http_method_from_name,
is_handler_reference, join_route_paths, member_target, named_children, push_route_registration,
string_literal_value, text, visit_named_nodes,
attach_route_handler, call_site_from_node, http_method_from_name, is_handler_reference,
join_route_paths, member_target, named_children, push_route_registration, string_literal_value,
text, visit_named_nodes,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, CallSite, Framework};
@ -26,24 +26,21 @@ impl AuthExtractor for EchoExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
let mut groups = HashMap::new();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| match node.kind() {
"short_var_declaration" | "assignment_statement" => {
maybe_collect_group_binding(node, bytes, &mut groups)
}
"call_expression" => {
maybe_collect_group_use(node, bytes, &mut groups);
maybe_collect_route(root, node, bytes, path, rules, &groups, &mut model);
maybe_collect_route(root, node, bytes, path, rules, &groups, model);
}
_ => {}
});
model
}
}

View file

@ -1,8 +1,8 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_site_from_node, collect_top_level_units, http_method_from_name,
is_handler_reference, member_target, named_children, push_route_registration,
string_literal_value, visit_named_nodes,
attach_route_handler, call_site_from_node, http_method_from_name, is_handler_reference,
member_target, named_children, push_route_registration, string_literal_value,
visit_named_nodes,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, Framework};
@ -25,18 +25,14 @@ impl AuthExtractor for ExpressExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| {
if node.kind() == "call_expression" {
maybe_collect_route(root, node, bytes, path, rules, &mut model);
maybe_collect_route(root, node, bytes, path, rules, model);
}
});
model
}
}

View file

@ -1,8 +1,8 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_sites_from_value, collect_top_level_units, http_method_from_name,
is_handler_reference, member_target, named_children, object_property_value,
push_route_registration, string_literal_value, visit_named_nodes,
attach_route_handler, call_sites_from_value, http_method_from_name, is_handler_reference,
member_target, named_children, object_property_value, push_route_registration,
string_literal_value, visit_named_nodes,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, CallSite, Framework};
@ -25,19 +25,15 @@ impl AuthExtractor for FastifyExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| {
if node.kind() == "call_expression" {
maybe_collect_shorthand_route(root, node, bytes, path, rules, &mut model);
maybe_collect_route_object(root, node, bytes, path, rules, &mut model);
maybe_collect_shorthand_route(root, node, bytes, path, rules, model);
maybe_collect_route_object(root, node, bytes, path, rules, model);
}
});
model
}
}

View file

@ -4,15 +4,27 @@ use super::common::{
push_route_registration, string_literal_value, text, visit_named_nodes,
};
use crate::auth_analysis::config::{AuthAnalysisRules, matches_name};
use crate::auth_analysis::extract::common::{collect_top_level_units, decorated_definition_child};
use crate::auth_analysis::model::{AuthorizationModel, CallSite, Framework, HttpMethod};
use crate::auth_analysis::extract::common::decorated_definition_child;
use crate::auth_analysis::model::{
AuthCheck, AuthCheckKind, AuthorizationModel, CallSite, Framework, HttpMethod,
};
use crate::labels::bare_method_name;
use crate::utils::project::{DetectedFramework, FrameworkContext};
use std::collections::HashMap;
use std::path::Path;
use tree_sitter::{Node, Tree};
pub struct FlaskExtractor;
/// Map from a module-level router/app variable name to the
/// `dependencies=[...]` deps declared on its constructor call. FastAPI
/// propagates these to every route attached via
/// `@<router>.<verb>(...)`, so the route extractor must merge them in
/// before running ownership / membership checks. Each entry follows
/// the same shape as `extract_fastapi_dependencies` produces:
/// `(CallSite, is_scoped_security)`. See `collect_router_level_dependencies`.
type RouterLevelDepMap = HashMap<String, Vec<(CallSite, bool)>>;
impl AuthExtractor for FlaskExtractor {
fn supports(&self, lang: &str, framework_ctx: Option<&FrameworkContext>) -> bool {
lang == "python"
@ -26,18 +38,52 @@ impl AuthExtractor for FlaskExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
// Pass 1: pre-walk for module-level router/app assignments
// (`ti_id_router = VersionedAPIRouter(dependencies=[Security(...)])`).
// FastAPI applies router-level deps to every attached route, so
// every per-route `@<router>.<verb>(...)` decorator must merge
// the router's deps before the ownership check fires. Without
// this, airflow's execution-API routes that re-use a single
// `ti_id_router` declared once at module scope inherit no auth
// and flag `missing_ownership_check` despite being authorized.
let mut router_deps = collect_router_level_dependencies(root, bytes);
// Merge in cross-file router-deps lifted via
// `<parent>.include_router(<this_file>.<router>, ...)` calls in
// other project files — pre-resolved by the orchestrator at
// pass 2 entry from `GlobalSummaries.router_facts_by_module`.
// Cross-file deps are PREPENDED to mirror FastAPI's runtime
// ordering (parent router deps run before any in-file router
// deps and before per-route deps). Empty when global summaries
// are unavailable (single-file scan / unit-test paths).
if !model.cross_file_router_deps.is_empty() {
for (router_var, cross_deps) in &model.cross_file_router_deps {
if cross_deps.is_empty() {
continue;
}
let entry = router_deps.entry(router_var.clone()).or_default();
let mut merged: Vec<(CallSite, bool)> = cross_deps.clone();
// Dedup so an inline `dependencies=[Security(...)]` and a
// cross-file lift of the same `Security(callee)` don't
// double-fire downstream auth checks.
for dep in entry.iter() {
let already = merged
.iter()
.any(|(call, scoped)| call.name == dep.0.name && *scoped == dep.1);
if !already {
merged.push(dep.clone());
}
}
*entry = merged;
}
}
visit_named_nodes(root, &mut |node| {
if node.kind() == "decorated_definition" {
maybe_collect_flask_route(root, node, bytes, path, rules, &mut model);
maybe_collect_flask_route(root, node, bytes, path, rules, model, &router_deps);
}
});
model
}
}
@ -54,6 +100,7 @@ fn maybe_collect_flask_route(
path: &Path,
rules: &AuthAnalysisRules,
model: &mut AuthorizationModel,
router_deps: &RouterLevelDepMap,
) {
let Some(definition) = decorated_definition_child(node) else {
return;
@ -63,21 +110,44 @@ fn maybe_collect_flask_route(
}
let mut route_specs = Vec::new();
let mut middleware_calls = Vec::new();
let mut middleware_calls: Vec<(CallSite, bool)> = Vec::new();
for decorator in decorator_expressions(node) {
if let Some(mut specs) = parse_flask_route_decorator(decorator, bytes) {
route_specs.append(&mut specs);
// FastAPI propagates router-level `dependencies=[...]` from
// `<router> = APIRouter(...)` to every attached
// `@<router>.<verb>(...)` route. Look up the decorator's
// router prefix in the pre-built map and merge its deps
// BEFORE the route-level deps so the ordering matches
// FastAPI runtime semantics (router deps run before route
// deps). Without this, airflow execution-API routes that
// declare auth once at the router level fire spurious
// `missing_ownership_check` / `token_override` findings.
if let Some(prefix) = router_prefix_from_decorator(decorator, bytes)
&& let Some(deps) = router_deps.get(&prefix)
{
middleware_calls.extend(deps.iter().cloned());
}
// FastAPI puts route-level dependencies (auth checks +
// logging hooks) inside the route decorator's
// `dependencies=[Depends(...)]` keyword argument, instead
// of as separate `@decorator` lines like Flask. Walk the
// route decorator's keyword args for that shape and lift
// each `Depends(call(...))` element into the
// middleware_calls list, so the same `inject_middleware_auth`
// path that Flask uses also picks up FastAPI auth deps.
// each `Depends(call(...))` / `Security(call, scopes=[...])`
// element into the middleware_calls list, so the same
// `inject_middleware_auth` path that Flask uses also
// picks up FastAPI auth deps. The boolean tracks whether
// the wrapper was a scoped `Security(...)` — those are
// OAuth2-scope-checked authorization (not just login),
// so the AuthCheckKind is promoted in
// `inject_middleware_auth`.
middleware_calls.extend(extract_fastapi_dependencies(decorator, bytes));
} else {
middleware_calls.extend(expand_decorator_calls(decorator, bytes));
middleware_calls.extend(
expand_decorator_calls(decorator, bytes)
.into_iter()
.map(|c| (c, false)),
);
}
}
@ -104,6 +174,10 @@ fn maybe_collect_flask_route(
rules,
);
let registration_calls: Vec<CallSite> = middleware_calls
.iter()
.map(|(call, _)| call.clone())
.collect();
push_route_registration(
model,
Framework::Flask,
@ -111,7 +185,7 @@ fn maybe_collect_flask_route(
spec.path,
path,
handler,
middleware_calls.clone(),
registration_calls,
);
}
}
@ -272,19 +346,25 @@ fn expand_decorator_calls(node: Node<'_>, bytes: &[u8]) -> Vec<CallSite> {
}
/// Walk the route-decorator call's keyword args looking for the FastAPI
/// `dependencies=[Depends(call(...)), Depends(call), ...]` shape. For
/// each `Depends(...)` list element, extract the inner callable as a
/// `CallSite` so it can flow through `inject_middleware_auth` and be
/// matched against the per-language authorization-check / login-guard
/// name lists. Refuses non-call elements and `Depends(...)` without a
/// recognised inner call shape.
/// `dependencies=[Depends(call(...)), Security(call, scopes=[...]), ...]`
/// shape. For each `Depends(...)` / `Security(...)` list element,
/// extract the inner callable as a `CallSite` so it can flow through
/// `inject_middleware_auth` and be matched against the per-language
/// authorization-check / login-guard name lists. Refuses non-call
/// elements and markers without a recognised inner call shape.
///
/// Returns `(CallSite, is_scoped_security)` pairs. The boolean is
/// `true` when the wrapper was `Security(...)` carrying a non-empty
/// `scopes=[...]` kwarg — those are OAuth2-scope-checked authorization
/// (FastAPI semantics), not bare login dependency, so
/// `inject_middleware_auth` promotes the `AuthCheckKind`.
///
/// The function is decoupled from Flask semantics (Flask routes never
/// use `dependencies=`); the lookup is purely structural and matches
/// FastAPI's documented dependency-injection convention. Lives in the
/// flask module because Flask's route-decorator parser already targets
/// the `@<router>.<method>(<path>, ...)` shape that FastAPI shares.
fn extract_fastapi_dependencies(decorator_expr: Node<'_>, bytes: &[u8]) -> Vec<CallSite> {
fn extract_fastapi_dependencies(decorator_expr: Node<'_>, bytes: &[u8]) -> Vec<(CallSite, bool)> {
if decorator_expr.kind() != "call" {
return Vec::new();
}
@ -296,47 +376,232 @@ fn extract_fastapi_dependencies(decorator_expr: Node<'_>, bytes: &[u8]) -> Vec<C
};
let mut out = Vec::new();
for element in named_children(value) {
if let Some(call) = unwrap_depends_call(element, bytes) {
out.push(call);
if let Some(unwrapped) = unwrap_depends_call(element, bytes) {
out.push(unwrapped);
}
}
out
}
/// Unwrap one `Depends(...)` list element from a FastAPI `dependencies`
/// list and return the inner callable as a `CallSite`. Three shapes
/// are accepted:
/// Walk the module root for top-level assignments of the form
/// `<router> = <RouterCtor>(..., dependencies=[Depends(...), Security(...)])`
/// and build a map from the router variable name to its router-level
/// dependency CallSites. FastAPI applies these to every attached
/// `@<router>.<verb>(...)` route at runtime — the per-route extractor
/// merges them in before running ownership / membership checks.
///
/// Recognised router/app constructors (callee-tail-name match, so
/// `fastapi.APIRouter(...)` and `routing.APIRouter(...)` both work):
/// * `APIRouter` (FastAPI canonical)
/// * `FastAPI` (FastAPI app object — `dependencies=[...]` on the app
/// applies to every route under it)
/// * `VersionedAPIRouter` (airflow-specific subclass)
/// * Any callee whose tail name ends with `Router` — covers
/// project-specific `APIRouter` subclasses without the airflow-
/// specific allowlist needing to grow per-codebase. Conservative:
/// the lookup only ever fires when the route decorator's prefix
/// matches a captured variable, so over-matching the constructor
/// doesn't produce false auth attribution unless the same name is
/// also used as a route decorator's receiver — extremely rare.
///
/// The walk is restricted to module-root expression statements / typed
/// assignments — nested function-local routers aren't supported (and
/// don't appear in real-world FastAPI codebases — the router pattern is
/// always module-scoped so it can be imported into the app at startup).
fn collect_router_level_dependencies(root: Node<'_>, bytes: &[u8]) -> RouterLevelDepMap {
let mut out: RouterLevelDepMap = HashMap::new();
for child in named_children(root) {
// Top-level shape: `expression_statement` wrapping an
// `assignment` (Python tree-sitter convention). Also accept
// bare `assignment` in case the grammar changes.
let assign = match child.kind() {
"expression_statement" => named_children(child).into_iter().next(),
"assignment" => Some(child),
_ => None,
};
let Some(assign) = assign else { continue };
if assign.kind() != "assignment" {
continue;
}
let Some(left) = assign.child_by_field_name("left") else {
continue;
};
if left.kind() != "identifier" {
continue;
}
let Some(right) = assign.child_by_field_name("right") else {
continue;
};
if right.kind() != "call" {
continue;
}
let Some(function) = right.child_by_field_name("function") else {
continue;
};
let function_text = text(function, bytes);
if !is_router_like_constructor(&function_text) {
continue;
}
let Some(arguments) = right.child_by_field_name("arguments") else {
continue;
};
let Some(deps_value) = keyword_argument_value(arguments, bytes, "dependencies") else {
continue;
};
let mut deps = Vec::new();
for element in named_children(deps_value) {
if let Some(unwrapped) = unwrap_depends_call(element, bytes) {
deps.push(unwrapped);
}
}
if deps.is_empty() {
continue;
}
let var_name = text(left, bytes).trim().to_string();
if var_name.is_empty() {
continue;
}
// First declaration wins. A `<router> = …` re-assignment
// would be unusual at module scope; if it happens, the first
// dependency declaration is conservatively the one that
// applies to most routes attached after it.
out.entry(var_name).or_insert(deps);
}
out
}
/// True for callee text that looks like a FastAPI router or app
/// constructor. Tail-name match (after the last `.`) so
/// `fastapi.APIRouter` / `routing.APIRouter` / bare `APIRouter` all
/// hit, plus airflow's `VersionedAPIRouter` subclass and any project-
/// specific `*Router` callable. See `collect_router_level_dependencies`
/// for the wider rationale.
fn is_router_like_constructor(callee: &str) -> bool {
let trimmed = callee.trim();
let tail = trimmed.rsplit('.').next().unwrap_or(trimmed);
if tail == "APIRouter" || tail == "FastAPI" || tail == "VersionedAPIRouter" {
return true;
}
// `*Router` suffix — covers project-specific subclasses without an
// exhaustive allowlist. Reject empty / single-char / lowercase
// tails to avoid catching arbitrary identifiers.
if tail.len() > "Router".len()
&& tail.ends_with("Router")
&& tail.chars().next().is_some_and(|c| c.is_ascii_uppercase())
{
return true;
}
false
}
/// Extract the router-receiver identifier from a route-decorator call
/// node. Decorator shape: `@<router>.<verb>(<path>, ...)` — the
/// callee is `<router>.<verb>`, so the prefix is everything before the
/// last `.`. Returns `None` for decorators that don't match the
/// expected `attribute`-style shape (e.g. bare `@requires_auth` or
/// `@blueprint.route("/x")` where the attribute is the verb itself).
fn router_prefix_from_decorator(decorator_expr: Node<'_>, bytes: &[u8]) -> Option<String> {
if decorator_expr.kind() != "call" {
return None;
}
let function = decorator_expr.child_by_field_name("function")?;
if function.kind() != "attribute" {
return None;
}
let object = function.child_by_field_name("object")?;
if !matches!(object.kind(), "identifier" | "attribute") {
return None;
}
let prefix = text(object, bytes).trim().to_string();
if prefix.is_empty() {
None
} else {
Some(prefix)
}
}
/// Unwrap one `Depends(...)` / `Security(...)` list element from a
/// FastAPI `dependencies` list and return the inner callable as a
/// `CallSite`. Four shapes are accepted:
/// * `Depends(callee(arg1, arg2))`, most common, the inner call is
/// the callable factory invocation; record `callee` as the auth
/// check.
/// * `Depends(callee)`, bare reference; record `callee` itself.
/// * `Depends()` / non-`Depends` items, skipped.
fn unwrap_depends_call(node: Node<'_>, bytes: &[u8]) -> Option<CallSite> {
/// * `Security(callee, scopes=[...])`, FastAPI's OAuth2-scope
/// variant of `Depends`; the first positional arg is the auth
/// callable, the `scopes=` kwarg is ignored. Real-world airflow
/// execution-API routes use this form
/// (`task_instances.py:104`).
/// * `Depends()` / non-marker items, skipped.
///
/// Skips `keyword_argument` children when locating the first
/// positional, so kwargs ordering (`Security(scopes=..., callee)`)
/// does not hide the dependency.
fn unwrap_depends_call(node: Node<'_>, bytes: &[u8]) -> Option<(CallSite, bool)> {
if node.kind() != "call" {
return None;
}
let function = node.child_by_field_name("function")?;
let function_text = text(function, bytes);
if !is_depends_callee(&function_text) {
if !is_dep_marker_callee(&function_text) {
return None;
}
let is_security = is_security_marker(&function_text);
let arguments = node.child_by_field_name("arguments")?;
let first = named_children(arguments).into_iter().next()?;
let children = named_children(arguments);
let first = children
.iter()
.copied()
.find(|child| child.kind() != "keyword_argument")?;
let scoped_security = is_security
&& keyword_argument_value(arguments, bytes, "scopes")
.map(|value| {
named_children(value)
.iter()
.any(|item| item.kind() != "comment")
})
.unwrap_or(false);
match first.kind() {
"call" => Some(call_site_from_node(first, bytes)),
"identifier" | "attribute" | "scoped_identifier" => Some(call_site_from_node(first, bytes)),
"call" => Some((call_site_from_node(first, bytes), scoped_security)),
"identifier" | "attribute" | "scoped_identifier" => {
Some((call_site_from_node(first, bytes), scoped_security))
}
_ => None,
}
}
/// True for the FastAPI `Depends` marker, including the
/// fully-qualified `fastapi.Depends` form. Conservative: only literal
/// matches, no canonicalisation.
fn is_depends_callee(callee: &str) -> bool {
/// Subset of `is_dep_marker_callee` that matches only the `Security`
/// variant (and its fully-qualified forms). `Security(callable,
/// scopes=[...])` is FastAPI's OAuth2-scope-checked dependency: the
/// inner callable is invoked with the merged `SecurityScopes` from
/// every parent `Security(...)` declaration, and the route is
/// rejected unless the bearer token carries one of the requested
/// scopes. Treating a scoped Security wrapper as authorization
/// (not just login) is the deeper semantic encoded by
/// `inject_middleware_auth`.
fn is_security_marker(callee: &str) -> bool {
let trimmed = callee.trim();
matches!(
trimmed,
"Depends" | "fastapi.Depends" | "fastapi.params.Depends"
"Security" | "fastapi.Security" | "fastapi.params.Security"
)
}
/// True for the FastAPI dependency markers `Depends` and `Security`,
/// including their fully-qualified forms. `Security(callable,
/// scopes=[...])` is the OAuth2-scope variant of `Depends(callable)`;
/// FastAPI treats the inner callable identically for dep-injection
/// purposes. Conservative: only literal matches, no canonicalisation.
fn is_dep_marker_callee(callee: &str) -> bool {
let trimmed = callee.trim();
matches!(
trimmed,
"Depends"
| "fastapi.Depends"
| "fastapi.params.Depends"
| "Security"
| "fastapi.Security"
| "fastapi.params.Security"
)
}
@ -344,31 +609,108 @@ fn inject_middleware_auth(
model: &mut AuthorizationModel,
unit_idx: usize,
line: usize,
middleware_calls: &[CallSite],
middleware_calls: &[(CallSite, bool)],
rules: &AuthAnalysisRules,
) {
let Some(unit) = model.units.get_mut(unit_idx) else {
return;
};
for call in middleware_calls {
if let Some(mut check) = auth_check_from_call_site(call, line, rules) {
// Mark as route-level: the check is declared at the route
// boundary (Flask `@requires_role(...)` decorator, FastAPI
// `dependencies=[Depends(...)]`, or any custom-router
// equivalent) and semantically authorizes every value the
// handler receives, path param, body, query, downstream
// row fetches, the lot. `auth_check_covers_subject` reads
// `is_route_level` and short-circuits `true` for any
// non-login-guard match, which is the correct shape for a
// decorator-level guard whose inner call carries no
// per-arg subject ref pointing back into the handler body.
// LoginGuard / TokenExpiry / TokenRecipient kinds are
// already excluded by `has_prior_subject_auth`'s filter
// before they reach `auth_check_covers_subject`, so the
// flag is safe to set unconditionally here, it has no
// effect on those kinds.
check.is_route_level = true;
unit.auth_checks.push(check);
for (call, scoped_security) in middleware_calls {
let mut check = match auth_check_from_call_site(call, line, rules) {
Some(check) => check,
None if *scoped_security => {
// FastAPI `Security(callable, scopes=[...])` always
// enforces authorization at the route boundary even
// when `callable` doesn't appear in any per-language
// login-guard / authorization-check name list. Synthesise
// an `Other`-kind check so the route is recognised as
// guarded; without this, every `Security(custom_dep,
// scopes=[...])` route fires `missing_ownership_check`
// FPs.
AuthCheck {
kind: AuthCheckKind::Other,
callee: call.name.clone(),
subjects: Vec::new(),
span: call.span,
line,
args: call.args.clone(),
condition_text: None,
is_route_level: false,
}
}
None => continue,
};
// Mark as route-level: the check is declared at the route
// boundary (Flask `@requires_role(...)` decorator, FastAPI
// `dependencies=[Depends(...)]`, or any custom-router
// equivalent) and semantically authorizes every value the
// handler receives, path param, body, query, downstream
// row fetches, the lot. `auth_check_covers_subject` reads
// `is_route_level` and short-circuits `true` for any
// non-login-guard match, which is the correct shape for a
// decorator-level guard whose inner call carries no
// per-arg subject ref pointing back into the handler body.
// LoginGuard / TokenExpiry / TokenRecipient kinds are
// already excluded by `has_prior_subject_auth`'s filter
// before they reach `auth_check_covers_subject`, so the
// flag is safe to set unconditionally here, it has no
// effect on those kinds.
check.is_route_level = true;
// FastAPI `Security(callable, scopes=[...])` is OAuth2-scope-
// checked authorization (the JWT must carry one of the listed
// scopes); a `LoginGuard` classification would be wrong because
// `has_prior_subject_auth` filters LoginGuard out. Promote to
// `Other` so the route counts as authorized for ownership /
// membership / token-override checks.
if *scoped_security
&& matches!(
check.kind,
AuthCheckKind::LoginGuard
| AuthCheckKind::TokenExpiry
| AuthCheckKind::TokenRecipient
)
{
check.kind = AuthCheckKind::Other;
}
let push_token_synth = *scoped_security;
unit.auth_checks.push(check);
if push_token_synth {
// FastAPI `Security(callable, scopes=[...])` validates the
// bearer JWT in two ways: signature verification (which
// includes expiry — a JWT past its `exp` claim fails the
// signature path) and scope checking (the requested scopes
// identify what the bearer is authorized to act on, which
// semantically encodes recipient binding for the route).
// Synthesise the matching `TokenExpiry` + `TokenRecipient`
// checks so the `token_override_without_validation` rule
// recognises the JWT-validated route. Without this,
// every FastAPI route declaring scoped Security at the
// route or router boundary fires token-override FPs on
// its `session.add` / `Model.save()` calls — the
// missing_ownership_check sibling of the same finding is
// already cleared by the kind-promotion above. Empty- or
// missing-scopes Security wrappers fall through this gate
// (scoped_security is false) and remain bare login deps.
unit.auth_checks.push(AuthCheck {
kind: AuthCheckKind::TokenExpiry,
callee: call.name.clone(),
subjects: Vec::new(),
span: call.span,
line,
args: call.args.clone(),
condition_text: None,
is_route_level: true,
});
unit.auth_checks.push(AuthCheck {
kind: AuthCheckKind::TokenRecipient,
callee: call.name.clone(),
subjects: Vec::new(),
span: call.span,
line,
args: call.args.clone(),
condition_text: None,
is_route_level: true,
});
}
}
}
@ -410,24 +752,318 @@ mod test_decorator_tests {
#[cfg(test)]
mod fastapi_dependencies_tests {
use super::is_depends_callee;
use super::{is_dep_marker_callee, is_security_marker, unwrap_depends_call};
use tree_sitter::Parser;
/// `is_depends_callee` only matches the FastAPI `Depends` marker.
/// Any other wrapper call inside `dependencies=[...]` is ignored ,
/// extracting an inner callee from the wrong wrapper would
/// misclassify logging hooks or filter callables as auth checks.
fn parse_python(source: &str) -> tree_sitter::Tree {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_python::LANGUAGE))
.expect("python language");
parser.parse(source, None).expect("parse")
}
/// Walk the parsed tree to find the first `call` node whose
/// callee name matches `marker`. Helper for the `unwrap_depends_call`
/// regression tests below — the production extractor traverses the
/// route-decorator's `dependencies=[...]` list and feeds each
/// element into `unwrap_depends_call`, so the test mirrors that
/// element shape directly without the surrounding boilerplate.
fn find_first_marker_call<'a>(
node: tree_sitter::Node<'a>,
bytes: &[u8],
marker: &str,
) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "call"
&& let Some(function) = node.child_by_field_name("function")
&& function.utf8_text(bytes).unwrap_or("") == marker
{
return Some(node);
}
for idx in 0..node.named_child_count() {
if let Some(child) = node.named_child(idx as u32)
&& let Some(found) = find_first_marker_call(child, bytes, marker)
{
return Some(found);
}
}
None
}
/// `is_dep_marker_callee` matches only FastAPI's `Depends` /
/// `Security` markers. Any other wrapper call inside
/// `dependencies=[...]` is ignored, extracting an inner callee
/// from the wrong wrapper would misclassify logging hooks or
/// filter callables as auth checks.
#[test]
fn is_depends_callee_recognises_canonical_forms() {
assert!(is_depends_callee("Depends"));
assert!(is_depends_callee("fastapi.Depends"));
assert!(is_depends_callee("fastapi.params.Depends"));
fn is_dep_marker_callee_recognises_canonical_forms() {
assert!(is_dep_marker_callee("Depends"));
assert!(is_dep_marker_callee("fastapi.Depends"));
assert!(is_dep_marker_callee("fastapi.params.Depends"));
// Security variant — OAuth2-scope-bearing equivalent.
assert!(is_dep_marker_callee("Security"));
assert!(is_dep_marker_callee("fastapi.Security"));
assert!(is_dep_marker_callee("fastapi.params.Security"));
// Whitespace tolerance.
assert!(is_depends_callee(" Depends "));
assert!(is_dep_marker_callee(" Depends "));
assert!(is_dep_marker_callee(" Security "));
// Negatives.
assert!(!is_depends_callee("Annotated"));
assert!(!is_depends_callee("Body"));
assert!(!is_depends_callee("Depends.something"));
assert!(!is_depends_callee("RequiresAuth"));
assert!(!is_depends_callee(""));
assert!(!is_dep_marker_callee("Annotated"));
assert!(!is_dep_marker_callee("Body"));
assert!(!is_dep_marker_callee("Depends.something"));
assert!(!is_dep_marker_callee("Security.something"));
assert!(!is_dep_marker_callee("RequiresAuth"));
assert!(!is_dep_marker_callee(""));
}
/// `is_security_marker` is the strictly-Security subset. Used to
/// promote the wrapper's `is_scoped_security` flag without a
/// second string-match pass.
#[test]
fn is_security_marker_recognises_security_only() {
assert!(is_security_marker("Security"));
assert!(is_security_marker("fastapi.Security"));
assert!(is_security_marker("fastapi.params.Security"));
assert!(is_security_marker(" Security "));
// Depends is NOT a Security marker.
assert!(!is_security_marker("Depends"));
assert!(!is_security_marker("fastapi.Depends"));
assert!(!is_security_marker("Annotated"));
assert!(!is_security_marker(""));
}
/// `Security(callable, scopes=[...])` — the canonical airflow
/// execution-API auth-dep shape (`task_instances.py:104`). Must
/// extract `callable` as the inner CallSite AND flag the wrapper as
/// scoped-security so `inject_middleware_auth` promotes the
/// AuthCheckKind from LoginGuard to Other (OAuth2 scopes are
/// authorization, not just login). Without the promotion, the
/// route still fires `missing_ownership_check` despite carrying a
/// declared route-level dependency.
#[test]
fn unwrap_depends_call_security_with_scopes_flags_scoped() {
let src = "x = Security(require_auth, scopes=[\"token:execution\"])\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let call = find_first_marker_call(tree.root_node(), bytes, "Security")
.expect("Security call node");
let (site, scoped) = unwrap_depends_call(call, bytes).expect("Security recognised");
assert_eq!(site.name, "require_auth");
assert!(
scoped,
"non-empty scopes=[...] must mark the wrapper scoped"
);
}
/// `Depends(callable())` — pre-existing FastAPI shape. Inner call
/// extracts to the factory's outer name; wrapper is NOT
/// scoped-security. Regression guard: the Security extension must
/// not flip Depends's scoped flag on.
#[test]
fn unwrap_depends_call_depends_factory_not_scoped() {
let src = "x = Depends(requires_access_dag(method=\"GET\"))\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let call =
find_first_marker_call(tree.root_node(), bytes, "Depends").expect("Depends call node");
let (site, scoped) = unwrap_depends_call(call, bytes).expect("Depends recognised");
assert_eq!(site.name, "requires_access_dag");
assert!(!scoped, "Depends wrapper never scoped-security");
}
/// `Security(callable)` without scopes (rare but legal) is NOT
/// scoped — the OAuth2-scope semantic only fires when scopes is
/// non-empty, so the wrapper falls back to the regular login-guard
/// classification. Conservative: don't over-promote.
#[test]
fn unwrap_depends_call_security_without_scopes_not_scoped() {
let src = "x = Security(require_auth)\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let call = find_first_marker_call(tree.root_node(), bytes, "Security")
.expect("Security call node");
let (site, scoped) = unwrap_depends_call(call, bytes).expect("Security recognised");
assert_eq!(site.name, "require_auth");
assert!(
!scoped,
"missing scopes=[...] kwarg means not scoped-security"
);
}
/// `Security(callable, scopes=[])` with an empty scope list is NOT
/// scoped-security: an empty `scopes=[]` declaration accumulates
/// no required scopes onto the JWT check, so the route is
/// effectively a bare login dependency. Conservative — keeps the
/// promotion gate tight.
#[test]
fn unwrap_depends_call_security_empty_scopes_not_scoped() {
let src = "x = Security(require_auth, scopes=[])\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let call = find_first_marker_call(tree.root_node(), bytes, "Security")
.expect("Security call node");
let (site, scoped) = unwrap_depends_call(call, bytes).expect("Security recognised");
assert_eq!(site.name, "require_auth");
assert!(!scoped, "scopes=[] is not scoped-security");
}
}
#[cfg(test)]
mod router_level_dependencies_tests {
use super::{
collect_router_level_dependencies, is_router_like_constructor, router_prefix_from_decorator,
};
use tree_sitter::Parser;
fn parse_python(source: &str) -> tree_sitter::Tree {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter::Language::from(tree_sitter_python::LANGUAGE))
.expect("python language");
parser.parse(source, None).expect("parse")
}
/// Tail-name match: `fastapi.APIRouter`, `routing.APIRouter`, bare
/// `APIRouter`, plus airflow's `VersionedAPIRouter` subclass. Suffix
/// rule covers project-specific `*Router` subclasses without an
/// exhaustive allowlist. Negatives must reject arbitrary lowercase
/// or non-router identifiers.
#[test]
fn is_router_like_constructor_matches_canonical_names() {
// Canonical FastAPI.
assert!(is_router_like_constructor("APIRouter"));
assert!(is_router_like_constructor("FastAPI"));
assert!(is_router_like_constructor("fastapi.APIRouter"));
assert!(is_router_like_constructor("fastapi.routing.APIRouter"));
assert!(is_router_like_constructor("fastapi.FastAPI"));
// Airflow.
assert!(is_router_like_constructor("VersionedAPIRouter"));
// Project-specific *Router subclasses.
assert!(is_router_like_constructor("CustomRouter"));
assert!(is_router_like_constructor("api.v1.MyRouter"));
// Negatives.
assert!(!is_router_like_constructor("router"));
assert!(!is_router_like_constructor("Annotated"));
assert!(!is_router_like_constructor("Depends"));
assert!(!is_router_like_constructor("Security"));
assert!(!is_router_like_constructor(""));
// `Router` alone is too short / generic to match the suffix
// rule (would over-fire on any callable named exactly
// `Router`); we accept it explicitly nowhere.
assert!(!is_router_like_constructor("Router"));
// `flat_router` ends with `Router` but starts lowercase —
// suffix rule requires uppercase first char to avoid catching
// generic verbs.
assert!(!is_router_like_constructor("flat_router"));
}
/// Airflow's `ti_id_router = VersionedAPIRouter(route_class=...,
/// dependencies=[Security(require_auth, scopes=["ti:self"])])` is
/// the canonical real-repo shape. The collector must capture the
/// `Security(require_auth, scopes=...)` dep keyed by
/// `ti_id_router`, and the wrapper must be flagged scoped-security
/// so `inject_middleware_auth` promotes the AuthCheckKind to Other.
#[test]
fn collect_router_level_dependencies_picks_up_versioned_apirouter_security() {
let src = "ti_id_router = VersionedAPIRouter(\n route_class=ExecutionAPIRoute,\n dependencies=[\n Security(require_auth, scopes=[\"ti:self\"]),\n ],\n)\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let map = collect_router_level_dependencies(tree.root_node(), bytes);
let deps = map
.get("ti_id_router")
.expect("ti_id_router router-level deps captured");
assert_eq!(deps.len(), 1);
let (site, scoped) = &deps[0];
assert_eq!(site.name, "require_auth");
assert!(*scoped, "scopes=[\"ti:self\"] must mark scoped-security");
}
/// Bare `Depends(...)` router-level dep (no scopes) — captured but
/// NOT scoped-security. Mirrors the per-route Depends test in the
/// sibling fastapi_dependencies_tests module.
#[test]
fn collect_router_level_dependencies_picks_up_apirouter_depends_not_scoped() {
let src = "v1 = APIRouter(\n prefix=\"/v1\",\n dependencies=[Depends(get_current_user)],\n)\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let map = collect_router_level_dependencies(tree.root_node(), bytes);
let deps = map.get("v1").expect("v1 router-level deps captured");
assert_eq!(deps.len(), 1);
let (site, scoped) = &deps[0];
assert_eq!(site.name, "get_current_user");
assert!(!*scoped, "Depends never scoped-security");
}
/// Constructor without `dependencies=` kwarg → no entry in the
/// map. Routers without router-level deps must not produce a
/// fake key — the per-route extractor would then merge an empty
/// list and silently no-op, but absence is the cleaner signal.
#[test]
fn collect_router_level_dependencies_skips_routers_without_deps() {
let src = "router = APIRouter(prefix=\"/x\")\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let map = collect_router_level_dependencies(tree.root_node(), bytes);
assert!(!map.contains_key("router"));
}
/// Non-router constructor (`MyService(...)`) with a coincidental
/// `dependencies=` kwarg must NOT enter the router-dep map.
/// `MyService` doesn't end with `Router` and isn't on the explicit
/// allowlist, so the gate rejects it.
#[test]
fn collect_router_level_dependencies_skips_non_router_constructors() {
let src = "svc = MyService(dependencies=[Depends(get_db)])\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let map = collect_router_level_dependencies(tree.root_node(), bytes);
assert!(!map.contains_key("svc"));
}
/// Helper: parse a single decorated function and pull out the
/// decorator call so `router_prefix_from_decorator` can be tested
/// in isolation. Mirrors the `find_first_marker_call` helper in
/// the sibling test module.
fn find_first_decorator<'a>(node: tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "decorator"
&& let Some(child) = node.named_child(0)
{
return Some(child);
}
for idx in 0..node.named_child_count() {
if let Some(child) = node.named_child(idx as u32)
&& let Some(found) = find_first_decorator(child)
{
return Some(found);
}
}
None
}
/// `@ti_id_router.patch("/x")` → prefix `"ti_id_router"`. This is
/// the lookup key the per-route extractor uses to pull
/// router-level deps out of the map.
#[test]
fn router_prefix_from_decorator_extracts_simple_identifier() {
let src = "@ti_id_router.patch(\"/x\")\ndef f():\n pass\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let decorator = find_first_decorator(tree.root_node()).expect("decorator call node");
let prefix = router_prefix_from_decorator(decorator, bytes).expect("prefix extracted");
assert_eq!(prefix, "ti_id_router");
}
/// Bare-identifier decorators (`@requires_auth\ndef f(): ...`) and
/// non-attribute callees return None — there's no router prefix
/// to look up.
#[test]
fn router_prefix_from_decorator_returns_none_for_bare_decorator() {
let src = "@requires_auth\ndef f():\n pass\n";
let tree = parse_python(src);
let bytes = src.as_bytes();
let decorator = find_first_decorator(tree.root_node()).expect("decorator node");
// `@requires_auth` produces an `identifier` child, not a
// `call`, so router_prefix should None out at the call gate.
assert!(router_prefix_from_decorator(decorator, bytes).is_none());
}
}

View file

@ -1,8 +1,8 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_site_from_node, collect_top_level_units, http_method_from_name,
is_handler_reference, join_route_paths, member_target, named_children, push_route_registration,
string_literal_value, text, visit_named_nodes,
attach_route_handler, call_site_from_node, http_method_from_name, is_handler_reference,
join_route_paths, member_target, named_children, push_route_registration, string_literal_value,
text, visit_named_nodes,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, CallSite, Framework};
@ -26,24 +26,21 @@ impl AuthExtractor for GinExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
let mut groups = HashMap::new();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| match node.kind() {
"short_var_declaration" | "assignment_statement" => {
maybe_collect_group_binding(node, bytes, &mut groups)
}
"call_expression" => {
maybe_collect_group_use(node, bytes, &mut groups);
maybe_collect_route(root, node, bytes, path, rules, &groups, &mut model);
maybe_collect_route(root, node, bytes, path, rules, &groups, model);
}
_ => {}
});
model
}
}

View file

@ -1,8 +1,8 @@
use super::AuthExtractor;
use super::common::{
attach_route_handler, call_site_from_node, collect_top_level_units, http_method_from_name,
is_handler_reference, member_target, named_children, push_route_registration,
string_literal_value, visit_named_nodes,
attach_route_handler, call_site_from_node, http_method_from_name, is_handler_reference,
member_target, named_children, push_route_registration, string_literal_value,
visit_named_nodes,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, Framework};
@ -25,18 +25,14 @@ impl AuthExtractor for KoaExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
visit_named_nodes(root, &mut |node| {
if node.kind() == "call_expression" {
maybe_collect_route(root, node, bytes, path, rules, &mut model);
maybe_collect_route(root, node, bytes, path, rules, model);
}
});
model
}
}

View file

@ -1,6 +1,7 @@
use super::config::AuthAnalysisRules;
use super::model::AuthorizationModel;
use super::model::{AuthorizationModel, CallSite};
use crate::utils::project::{FrameworkContext, rust_file_imports_web_framework};
use std::collections::HashMap;
use std::path::Path;
use tree_sitter::Tree;
@ -21,13 +22,26 @@ pub mod spring;
pub trait AuthExtractor {
fn supports(&self, lang: &str, framework_ctx: Option<&FrameworkContext>) -> bool;
/// Returns true when this extractor expects the orchestrator to
/// have already populated `model.units` with one
/// `AnalysisUnitKind::Function` entry per top-level function /
/// method via [`common::collect_top_level_units`]. Defaults to
/// `true`; framework extractors that build their own unit set
/// (Spring, Rails) override to `false` so the orchestrator skips
/// the shared collection pass when only those extractors match.
fn requires_top_level_units(&self) -> bool {
true
}
fn extract(
&self,
tree: &Tree,
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel;
model: &mut AuthorizationModel,
);
}
pub fn extract_authorization_model(
@ -37,6 +51,7 @@ pub fn extract_authorization_model(
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
cross_file_router_deps: Option<&HashMap<String, Vec<(CallSite, bool)>>>,
) -> AuthorizationModel {
let extractors: [&dyn AuthExtractor; 13] = [
&express::ExpressExtractor,
@ -57,14 +72,47 @@ pub fn extract_authorization_model(
lang: lang.to_string(),
..Default::default()
};
// Pre-populate the cross-file router-dep map BEFORE extractors run.
// FlaskExtractor reads `model.cross_file_router_deps` and merges the
// resolved deps into its local router-deps map at extraction time,
// so per-route auth attribution sees both the local-file
// `dependencies=[Security(...)]` declarations and the cross-file
// lift from `<parent>.include_router(<this_file>.<router>, ...)`
// edges visible elsewhere in the project. Empty / `None` for every
// non-Python language and for files with no matching child edges.
if let Some(deps) = cross_file_router_deps {
model.cross_file_router_deps = deps.clone();
}
// **Hoist `collect_top_level_units` out of the per-extractor loop.**
// For multi-extractor languages (Go: gin+echo, JS/TS: express+koa+
// fastify, Python: flask+django, Rust: axum+actix_web+rocket, Ruby:
// sinatra) the legacy code re-walked the entire AST and rebuilt the
// `Function`-kind unit set per extractor (then deduped by span).
// `collect_top_level_units` was the dominant cost in
// `extract_authorization_model` (46% of total wall-clock on the
// mattermost/server/channels/app subtree, 2026-05-04 profile).
//
// After the hoist each extractor receives a `&mut model` that
// already carries the shared unit set; framework-specific work
// (route detection, middleware injection, typed-extractor guards)
// augments and promotes those units in place via the existing
// `attach_route_handler` "promote-or-create" path.
//
// Spring + Rails build their own unit set (`maybe_collect_controller`
// / Rails' `collect_nodes`), so they opt out via
// `requires_top_level_units = false`; the shared pass runs only
// when at least one matching extractor needs it.
let any_requires_units = extractors
.iter()
.any(|e| e.supports(lang, framework_ctx) && e.requires_top_level_units());
if any_requires_units {
common::collect_top_level_units(tree.root_node(), bytes, rules, &mut model);
}
for extractor in extractors {
if extractor.supports(lang, framework_ctx) {
let mut other = extractor.extract(tree, bytes, path, rules);
// Preserve the canonical `lang` set above; sub-extractors
// build their own default-initialised models with empty lang.
other.lang = model.lang.clone();
model.extend(other);
extractor.extract(tree, bytes, path, rules, &mut model);
}
}

View file

@ -22,17 +22,24 @@ impl AuthExtractor for RailsExtractor {
.is_none_or(|ctx| ctx.frameworks.is_empty() || ctx.has(DetectedFramework::Rails))
}
fn requires_top_level_units(&self) -> bool {
// Rails builds its own RouteHandler unit set inside `collect_nodes`
// (controller actions inferred from `routes.rb` resource entries
// and conventional `resources :foo` mappings). It never relies on
// the orchestrator's shared `collect_top_level_units` pass.
false
}
fn extract(
&self,
tree: &Tree,
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_nodes(root, &[], bytes, path, rules, &mut model);
model
collect_nodes(root, &[], bytes, path, rules, model);
}
}

View file

@ -4,8 +4,7 @@ use super::axum::{
rust_param_aliases,
};
use super::common::{
attach_route_handler, collect_top_level_units, function_definition_node, function_name,
named_children, text,
attach_route_handler, function_definition_node, function_name, named_children, text,
};
use crate::auth_analysis::config::AuthAnalysisRules;
use crate::auth_analysis::model::{AuthorizationModel, Framework, HttpMethod, RouteRegistration};
@ -28,14 +27,10 @@ impl AuthExtractor for RocketExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
collect_handlers(root, root, bytes, path, rules, &mut model);
model
collect_handlers(root, root, bytes, path, rules, model);
}
}

View file

@ -1,7 +1,7 @@
use super::AuthExtractor;
use super::common::{
auth_check_from_call_site, build_function_unit, call_name, call_site_from_node,
collect_top_level_units, named_children, span, string_literal_value,
auth_check_from_call_site, build_function_unit, call_name, call_site_from_node, named_children,
span, string_literal_value,
};
use crate::auth_analysis::config::{AuthAnalysisRules, matches_name};
use crate::auth_analysis::model::{
@ -27,13 +27,11 @@ impl AuthExtractor for SinatraExtractor {
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_top_level_units(root, bytes, rules, &mut model);
let before_filters = collect_before_filters(root, bytes);
collect_routes(root, bytes, path, rules, &before_filters, &mut model);
model
collect_routes(root, bytes, path, rules, &before_filters, model);
}
}

View file

@ -20,19 +20,27 @@ impl AuthExtractor for SpringExtractor {
.is_none_or(|ctx| ctx.frameworks.is_empty() || ctx.has(DetectedFramework::Spring))
}
fn requires_top_level_units(&self) -> bool {
// Spring synthesises its own units inside `maybe_collect_controller`
// (only `@Controller` / `@RestController`-annotated classes
// produce units; non-controller Java files contribute nothing).
// The orchestrator's shared `collect_top_level_units` pass would
// emit a `Function` unit per top-level method on every Java file
// including non-controller helpers, doubling work and broadening
// the analysis surface beyond what Spring needs.
false
}
fn extract(
&self,
tree: &Tree,
bytes: &[u8],
path: &Path,
rules: &AuthAnalysisRules,
) -> AuthorizationModel {
model: &mut AuthorizationModel,
) {
let root = tree.root_node();
let mut model = AuthorizationModel::default();
collect_classes(root, bytes, path, rules, &mut model);
model
collect_classes(root, bytes, path, rules, model);
}
}