mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
Performance and precision pass (#64)
This commit is contained in:
parent
c7c5e0f3a1
commit
fb698d2c27
97 changed files with 9932 additions and 517 deletions
|
|
@ -257,7 +257,18 @@ fn check_token_override_without_validation(
|
|||
continue;
|
||||
};
|
||||
let Some(final_write) = unit.operations.iter().rev().find(|operation| {
|
||||
operation.kind == OperationKind::Mutation && operation.line >= token_lookup.line
|
||||
operation.kind == OperationKind::Mutation
|
||||
&& operation.line >= token_lookup.line
|
||||
// Ignore `InMemoryLocal` mutations (HashSet/HashMap/Vec
|
||||
// local bookkeeping like `verified_ids.update(myteams)`,
|
||||
// `requested_teams.update(verified_ids)`). The verb is
|
||||
// `update` so `OperationKind::Mutation` is set, but the
|
||||
// sink_class encodes that the receiver is a non-sink
|
||||
// local container — never a token-bound write. Mirrors
|
||||
// the gate in `check_ownership_gaps`.
|
||||
&& operation
|
||||
.sink_class
|
||||
.is_none_or(|class| class.is_auth_relevant())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ pub struct AuthAnalysisRules {
|
|||
/// `WHERE <ACL>.user_id = ?N`, make every returned row
|
||||
/// membership-gated. See `sql_semantics::classify_sql_query`.
|
||||
pub acl_tables: Vec<String>,
|
||||
/// Callee names that, when they appear as the chain root of a
|
||||
/// chained-call shape (`select(X).filter_by(...)`,
|
||||
/// `query(X).filter(...)`), anchor the trailing method as a DB
|
||||
/// query-builder operation. Overrides the chained-call suppression
|
||||
/// in `classify_sink_class` for SQLAlchemy / similar query-builder
|
||||
/// idioms whose first call returns an opaque builder object.
|
||||
pub db_query_builder_roots: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthAnalysisRules {
|
||||
|
|
@ -80,6 +87,7 @@ impl AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,11 +104,13 @@ impl AuthAnalysisRules {
|
|||
}
|
||||
|
||||
/// Does `ty` (last path segment, case-sensitive) match a
|
||||
/// non-sink receiver type? The angle-bracket generic suffix is
|
||||
/// stripped first: `HashMap<i64, String>` → `HashMap`.
|
||||
/// non-sink receiver type? Generic suffixes are stripped first:
|
||||
/// `HashMap<i64, String>` → `HashMap` (Rust/Java/TS angle brackets),
|
||||
/// `set[int]` / `dict[str, int]` → `set` / `dict` (Python PEP 585
|
||||
/// builtin generics + `typing` aliases).
|
||||
pub fn is_non_sink_receiver_type(&self, ty: &str) -> bool {
|
||||
let base = Self::type_last_segment(ty);
|
||||
let base = base.split('<').next().unwrap_or(base).trim();
|
||||
let base = base.split(['<', '[']).next().unwrap_or(base).trim();
|
||||
self.non_sink_receiver_types
|
||||
.iter()
|
||||
.any(|allowed| allowed == base)
|
||||
|
|
@ -115,25 +125,35 @@ impl AuthAnalysisRules {
|
|||
/// The callee string may use either `::` or `.` as the path
|
||||
/// separator (nyx's `callee_name` normalizes both via
|
||||
/// `member_chain`).
|
||||
///
|
||||
/// Bare-callee form: Python uses `set()` / `dict()` / `list()` /
|
||||
/// `defaultdict()` / etc. as direct constructors with no method
|
||||
/// segment. When `callee` has no `.` / `::` separator and matches
|
||||
/// a registered non-sink receiver type, treat the call as a
|
||||
/// non-sink constructor. Closes the
|
||||
/// `verified_ids = set(); verified_ids.update(myteams)` shape in
|
||||
/// sentry where the bare-call form was unrecognised so the bound
|
||||
/// var was missing from `non_sink_vars` and the later
|
||||
/// `.update(..)` classified as DbMutation.
|
||||
pub fn is_non_sink_constructor_callee(&self, callee: &str) -> bool {
|
||||
let normalized = callee.replace("::", ".");
|
||||
let Some((ty, method)) = normalized.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
if !self.is_non_sink_receiver_type(ty) {
|
||||
return false;
|
||||
if let Some((ty, method)) = normalized.rsplit_once('.') {
|
||||
if !self.is_non_sink_receiver_type(ty) {
|
||||
return false;
|
||||
}
|
||||
return matches!(
|
||||
method,
|
||||
"new"
|
||||
| "with_capacity"
|
||||
| "with_capacity_and_hasher"
|
||||
| "with_hasher"
|
||||
| "from"
|
||||
| "from_iter"
|
||||
| "new_in"
|
||||
| "default"
|
||||
);
|
||||
}
|
||||
matches!(
|
||||
method,
|
||||
"new"
|
||||
| "with_capacity"
|
||||
| "with_capacity_and_hasher"
|
||||
| "with_hasher"
|
||||
| "from"
|
||||
| "from_iter"
|
||||
| "new_in"
|
||||
| "default"
|
||||
)
|
||||
self.is_non_sink_receiver_type(&normalized)
|
||||
}
|
||||
|
||||
/// Does the first segment of a callee receiver chain look like a
|
||||
|
|
@ -260,20 +280,45 @@ impl AuthAnalysisRules {
|
|||
// Verb-name fallback (`is_mutation` / `is_read`) is the loosest
|
||||
// dispatch: it prefix-matches the bare method name against
|
||||
// generic verbs (`Get`, `Save`, `Find`, …) regardless of the
|
||||
// receiver. When the receiver chain itself contains a call
|
||||
// expression (`w.Header().Get(..)`, `r.URL.Query().Get(..)`,
|
||||
// `db.Tx(..).Query(..)`), the receiver is the *return value of
|
||||
// another call*, its type is opaque to the auth analyser and
|
||||
// the bare verb match is too speculative to assume a data-layer
|
||||
// sink. The realtime/outbound/cache prefix dispatches above
|
||||
// already match by the chain root; if none of them claimed the
|
||||
// receiver, dropping the verb-name fallback for chained-call
|
||||
// shapes prevents the entire `w.Header().Get` /
|
||||
// `r.URL.Query().Get` cluster from masquerading as a
|
||||
// `DbCrossTenantRead`. A canonical data-layer call still has a
|
||||
// bare-identifier receiver (`repo.Find(id)`, `db.Query(..)`)
|
||||
// and is unaffected.
|
||||
if !receiver_is_chained_call(callee) {
|
||||
// receiver. Two structural shapes lack the receiver evidence
|
||||
// needed to anchor a DB-sink classification and are excluded:
|
||||
//
|
||||
// 1. Chained-call receiver (`w.Header().Get(..)`,
|
||||
// `r.URL.Query().Get(..)`, `db.Tx(..).Query(..)`) — the
|
||||
// receiver is the *return value of another call*, its type
|
||||
// is opaque to the auth analyser.
|
||||
// 2. Bare-identifier callee with no receiver dot at all
|
||||
// (`list(..)`, `filter(..)`, `create_audit_entry(..)`,
|
||||
// `update_coding_agent_state(..)`) — Python / JS / Ruby
|
||||
// builtins and locally-defined helpers routinely collide
|
||||
// with the verb vocabulary. Real ORM / DB calls always
|
||||
// carry a receiver (`User.find(id)`, `Model.objects.filter`,
|
||||
// `repo.save(x)`); a bare `list(events)` is the Python
|
||||
// builtin and `filter(fn, xs)` is `Iterable.filter`.
|
||||
//
|
||||
// The realtime / outbound / cache prefix dispatches above
|
||||
// already match by the chain root; gating the verb fallback on
|
||||
// a simple non-chained receiver dot prevents both shapes from
|
||||
// masquerading as data-layer sinks while leaving canonical
|
||||
// `repo.Find(id)` / `db.Query(..)` calls unaffected.
|
||||
if receiver_is_simple_chain(callee) {
|
||||
if self.is_mutation(callee) {
|
||||
return Some(SinkClass::DbMutation);
|
||||
}
|
||||
if self.is_read(callee) {
|
||||
return Some(SinkClass::DbCrossTenantRead);
|
||||
}
|
||||
}
|
||||
// SQLAlchemy / query-builder chained shapes:
|
||||
// `select(X).filter_by(...)`, `query(X).filter(...)`,
|
||||
// `select().join().where()`. The chain receiver is the return
|
||||
// value of an opaque builder primitive that the type tracker
|
||||
// cannot follow, but the chain *root* segment is itself a known
|
||||
// DB query-builder verb — strong enough evidence to anchor a
|
||||
// DB-sink classification when paired with a mutation/read verb
|
||||
// on the trailing method. Closes airflow-style
|
||||
// `session.scalar(select(C).filter_by(conn_id=user_input))`.
|
||||
if receiver_is_chained_call(callee) && self.chain_root_is_db_query_builder(callee) {
|
||||
if self.is_mutation(callee) {
|
||||
return Some(SinkClass::DbMutation);
|
||||
}
|
||||
|
|
@ -284,6 +329,42 @@ impl AuthAnalysisRules {
|
|||
None
|
||||
}
|
||||
|
||||
/// True when any non-final segment of the chain is an
|
||||
/// intermediate-call (ends with `()`) whose verb matches a
|
||||
/// configured `db_query_builder_roots` entry. Used to anchor
|
||||
/// chained-call shapes like `select(X).filter_by(id=...)` (Python)
|
||||
/// or `query(X).filter(...)` to a DB-sink classification despite
|
||||
/// the opaque builder return value.
|
||||
pub fn chain_root_is_db_query_builder(&self, callee: &str) -> bool {
|
||||
if self.db_query_builder_roots.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let segments: Vec<&str> = callee.split('.').collect();
|
||||
if segments.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
for seg in &segments[..segments.len() - 1] {
|
||||
if !seg.ends_with(')') {
|
||||
continue;
|
||||
}
|
||||
let stripped = seg
|
||||
.trim_end_matches(')')
|
||||
.trim_end_matches('(')
|
||||
.trim_end_matches(')');
|
||||
if stripped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.db_query_builder_roots
|
||||
.iter()
|
||||
.any(|root| matches_name(stripped, root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn requires_admin_path(&self, path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let normalized = if lower.starts_with('/') {
|
||||
|
|
@ -583,7 +664,29 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
"invitedemail".into(),
|
||||
"recipient".into(),
|
||||
],
|
||||
non_sink_receiver_types: Vec::new(),
|
||||
// Python builtin / `collections` non-sink container types.
|
||||
// Recognised both as type-annotation hints (`x: set[int]`)
|
||||
// and as bare-callee constructor forms (`x = set()`,
|
||||
// `cache = collections.defaultdict(list)`, …). Method
|
||||
// calls on bound vars (`x.update`, `x.add`, `cache.pop`)
|
||||
// are then classified as `InMemoryLocal`, suppressing the
|
||||
// false `DbMutation` / `DbCrossTenantRead` sink shape.
|
||||
// Closes sentry `api/helpers/teams.py:46` shape where
|
||||
// `verified_ids = set(); verified_ids.update(myteams)` was
|
||||
// flagged as cross-tenant mutation.
|
||||
non_sink_receiver_types: vec![
|
||||
"set".into(),
|
||||
"dict".into(),
|
||||
"list".into(),
|
||||
"tuple".into(),
|
||||
"frozenset".into(),
|
||||
"defaultdict".into(),
|
||||
"OrderedDict".into(),
|
||||
"Counter".into(),
|
||||
"deque".into(),
|
||||
"ChainMap".into(),
|
||||
"namedtuple".into(),
|
||||
],
|
||||
non_sink_receiver_name_prefixes: Vec::new(),
|
||||
non_sink_global_receivers: Vec::new(),
|
||||
non_sink_method_names: Vec::new(),
|
||||
|
|
@ -591,6 +694,12 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
// SQLAlchemy queryset builders. `select(X).filter_by(id=...)`
|
||||
// / `query(X).filter(id=...)` chains return opaque builder
|
||||
// objects whose type the auth analyser cannot follow; the
|
||||
// chain *root* primitive itself is the DB-anchor evidence.
|
||||
// Closes airflow-style `session.scalar(select(C).filter_by(...))`.
|
||||
db_query_builder_roots: vec!["select".into(), "query".into()],
|
||||
}
|
||||
} else if matches!(lang_slug, "ruby") {
|
||||
AuthAnalysisRules {
|
||||
|
|
@ -766,6 +875,7 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
} else if matches!(lang_slug, "go") {
|
||||
AuthAnalysisRules {
|
||||
|
|
@ -862,6 +972,7 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
} else if matches!(lang_slug, "java") {
|
||||
AuthAnalysisRules {
|
||||
|
|
@ -954,6 +1065,7 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
} else if matches!(lang_slug, "rust") {
|
||||
AuthAnalysisRules {
|
||||
|
|
@ -1137,6 +1249,7 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
"members".into(),
|
||||
"share_grants".into(),
|
||||
],
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
AuthAnalysisRules {
|
||||
|
|
@ -1290,6 +1403,7 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
outbound_network_receiver_prefixes: Vec::new(),
|
||||
cache_receiver_prefixes: Vec::new(),
|
||||
acl_tables: Vec::new(),
|
||||
db_query_builder_roots: Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1367,6 +1481,10 @@ pub fn build_auth_rules(config: &Config, lang_slug: &str) -> AuthAnalysisRules {
|
|||
&lang_cfg.auth.cache_receiver_prefixes,
|
||||
);
|
||||
extend_unique(&mut rules.acl_tables, &lang_cfg.auth.acl_tables);
|
||||
extend_unique(
|
||||
&mut rules.db_query_builder_roots,
|
||||
&lang_cfg.auth.db_query_builder_roots,
|
||||
);
|
||||
}
|
||||
|
||||
rules
|
||||
|
|
@ -1410,6 +1528,17 @@ pub fn receiver_is_chained_call(callee: &str) -> bool {
|
|||
receiver.contains('(')
|
||||
}
|
||||
|
||||
/// True when the callee has a non-chained receiver dot, i.e. an actual
|
||||
/// receiver identifier or path (`User.find`, `repo.save`,
|
||||
/// `Model.objects.filter`). Returns false for bare-identifier callees
|
||||
/// (`list(..)`, `filter(..)`, `create_audit_entry(..)`) and for
|
||||
/// chained-call receivers (`db.Tx(..).Query(..)`) — both lack the
|
||||
/// receiver evidence needed to anchor a DB-sink classification, see
|
||||
/// the comment in `classify_sink_class`.
|
||||
pub fn receiver_is_simple_chain(callee: &str) -> bool {
|
||||
callee.contains('.') && !receiver_is_chained_call(callee)
|
||||
}
|
||||
|
||||
/// Recognise `require_<resource>_<role>` / `ensure_<resource>_<role>`
|
||||
/// shapes where `<role>` is a closed-vocabulary authorization noun
|
||||
/// (`member`, `owner`, `admin`, `access`, `permission`, `manager`,
|
||||
|
|
@ -1768,6 +1897,161 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Pin the bare-identifier verb-fallback gate. Bare callees with
|
||||
/// no receiver dot lack the receiver evidence needed to anchor a
|
||||
/// DB-sink classification: `list(...)`, `filter(...)`, `update(...)`,
|
||||
/// `create_audit_entry(...)`, `update_coding_agent_state(...)` are
|
||||
/// Python builtins / JS Array methods / locally-defined helpers,
|
||||
/// not ORM operations. Closes the sentry / saleor / netbox cluster
|
||||
/// where bare-name callees inside route helpers (with `request:
|
||||
/// Request` triggering the user-input precondition) fired
|
||||
/// `py.auth.missing_ownership_check`.
|
||||
#[test]
|
||||
fn classify_sink_class_suppresses_bare_callee_verb_fallback() {
|
||||
use crate::auth_analysis::model::SinkClass;
|
||||
use std::collections::HashSet;
|
||||
let empty: HashSet<String> = HashSet::new();
|
||||
|
||||
for lang in [
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"go",
|
||||
"java",
|
||||
"ruby",
|
||||
"rust",
|
||||
] {
|
||||
let cfg = Config::default();
|
||||
let rules = build_auth_rules(&cfg, lang);
|
||||
// Bare callees that prefix-match a read / mutation indicator
|
||||
// must NOT classify as DbCrossTenantRead / DbMutation.
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("list", &empty),
|
||||
None,
|
||||
"lang={lang} bare list",
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("filter", &empty),
|
||||
None,
|
||||
"lang={lang} bare filter",
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("update", &empty),
|
||||
None,
|
||||
"lang={lang} bare update",
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("create_audit_entry", &empty),
|
||||
None,
|
||||
"lang={lang} bare create_audit_entry",
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("update_coding_agent_state", &empty),
|
||||
None,
|
||||
"lang={lang} bare update_coding_agent_state",
|
||||
);
|
||||
}
|
||||
|
||||
// Recall guard: qualified ORM / DB calls keep firing on every
|
||||
// language that has the verb in its indicator vocabulary.
|
||||
let py_rules = build_auth_rules(&Config::default(), "python");
|
||||
assert_eq!(
|
||||
py_rules.classify_sink_class("Project.objects.filter", &empty),
|
||||
Some(SinkClass::DbCrossTenantRead)
|
||||
);
|
||||
assert_eq!(
|
||||
py_rules.classify_sink_class("Project.objects.update", &empty),
|
||||
Some(SinkClass::DbMutation)
|
||||
);
|
||||
let go_rules = build_auth_rules(&Config::default(), "go");
|
||||
assert_eq!(
|
||||
go_rules.classify_sink_class("repo.Find", &empty),
|
||||
Some(SinkClass::DbCrossTenantRead)
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the SQLAlchemy queryset-builder chained-call recogniser.
|
||||
/// `select(X).filter_by(id=user_input)` reduces (post `member_chain`
|
||||
/// fix) to the chain-string `"select().filter_by"`. The chained-call
|
||||
/// shape would otherwise be suppressed by `receiver_is_chained_call`,
|
||||
/// blocking recall on the airflow `session.scalar(select(C).filter_by(...))`
|
||||
/// shape. `chain_root_is_db_query_builder` overrides the suppression
|
||||
/// when the chain root is a configured DB-builder verb.
|
||||
#[test]
|
||||
fn chain_root_is_db_query_builder_recognises_sqlalchemy_chains() {
|
||||
use crate::auth_analysis::model::SinkClass;
|
||||
use std::collections::HashSet;
|
||||
let cfg = Config::default();
|
||||
let py_rules = build_auth_rules(&cfg, "python");
|
||||
let empty: HashSet<String> = HashSet::new();
|
||||
|
||||
// Detection: chain root `select()` / `query()` matches the
|
||||
// configured Python `db_query_builder_roots`.
|
||||
assert!(py_rules.chain_root_is_db_query_builder("select().filter_by"));
|
||||
assert!(py_rules.chain_root_is_db_query_builder("query().filter"));
|
||||
assert!(py_rules.chain_root_is_db_query_builder("Session.query().filter"));
|
||||
assert!(py_rules.chain_root_is_db_query_builder("select().join().where"));
|
||||
// Non-builder chain roots: must not match.
|
||||
assert!(!py_rules.chain_root_is_db_query_builder("w.Header().Get"));
|
||||
assert!(!py_rules.chain_root_is_db_query_builder("obj.foo().bar"));
|
||||
// Plain receiver chains (no intermediate call): not handled
|
||||
// here — the simple-chain branch covers them.
|
||||
assert!(!py_rules.chain_root_is_db_query_builder("repo.Find"));
|
||||
assert!(!py_rules.chain_root_is_db_query_builder("Project.objects.filter"));
|
||||
// Classification: chained-call DB-builder shapes anchor to
|
||||
// DbCrossTenantRead / DbMutation when the trailing verb matches.
|
||||
assert_eq!(
|
||||
py_rules.classify_sink_class("select().filter_by", &empty),
|
||||
Some(SinkClass::DbCrossTenantRead)
|
||||
);
|
||||
assert_eq!(
|
||||
py_rules.classify_sink_class("query().delete", &empty),
|
||||
Some(SinkClass::DbMutation)
|
||||
);
|
||||
assert_eq!(
|
||||
py_rules.classify_sink_class("select().update", &empty),
|
||||
Some(SinkClass::DbMutation)
|
||||
);
|
||||
// Regression guard: chained-call shapes that are NOT DB
|
||||
// builders (Go HTTP `w.Header().get`, generic `obj.foo().bar`)
|
||||
// remain suppressed even when the trailing verb prefix-matches.
|
||||
// Run on a Python-rules instance with the verb in its read
|
||||
// indicator vocabulary to exercise the guard.
|
||||
assert_eq!(py_rules.classify_sink_class("w.Header().get", &empty), None);
|
||||
assert_eq!(py_rules.classify_sink_class("obj.foo().get", &empty), None);
|
||||
|
||||
// Languages without `db_query_builder_roots` defaults must not
|
||||
// false-positive on chained-call shapes.
|
||||
for lang in ["javascript", "typescript", "go", "java", "ruby", "rust"] {
|
||||
let rules = build_auth_rules(&Config::default(), lang);
|
||||
assert!(
|
||||
!rules.chain_root_is_db_query_builder("select().filter_by"),
|
||||
"lang={lang} unexpectedly classified select().filter_by as DB-builder chain",
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("select().filter_by", &empty),
|
||||
None,
|
||||
"lang={lang} unexpectedly classified select().filter_by as DB sink",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_is_simple_chain_classifies_correctly() {
|
||||
use super::receiver_is_simple_chain;
|
||||
// Simple receiver chain (allowed for verb fallback).
|
||||
assert!(receiver_is_simple_chain("repo.Find"));
|
||||
assert!(receiver_is_simple_chain("Project.objects.filter"));
|
||||
assert!(receiver_is_simple_chain("self.cache.insert"));
|
||||
// Bare-identifier callee (rejected — no receiver evidence).
|
||||
assert!(!receiver_is_simple_chain("list"));
|
||||
assert!(!receiver_is_simple_chain("filter"));
|
||||
assert!(!receiver_is_simple_chain("create_audit_entry"));
|
||||
// Chained-call receiver (rejected — receiver type opaque).
|
||||
assert!(!receiver_is_simple_chain("w.Header().Get"));
|
||||
assert!(!receiver_is_simple_chain("db.Tx(opts).Query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sink_class_is_auth_relevant_only_for_non_local_classes() {
|
||||
use crate::auth_analysis::model::SinkClass;
|
||||
|
|
@ -1836,6 +2120,97 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Pin the Python non-sink container recogniser. Both type
|
||||
/// annotations (`x: set[int]`, `m: dict[str, int]`) and
|
||||
/// bare-callee constructor calls (`set()`, `dict()`,
|
||||
/// `defaultdict()`) must register the bound variable as a
|
||||
/// non-sink receiver, suppressing later `.update(..)` /
|
||||
/// `.add(..)` calls from classifying as `DbMutation` /
|
||||
/// `DbCrossTenantRead`.
|
||||
#[test]
|
||||
fn python_non_sink_container_recognition() {
|
||||
use crate::auth_analysis::model::SinkClass;
|
||||
use std::collections::HashSet;
|
||||
let cfg = Config::default();
|
||||
let rules = build_auth_rules(&cfg, "python");
|
||||
|
||||
// Type annotations: PEP 585 builtin generics + typing aliases.
|
||||
assert!(rules.is_non_sink_receiver_type("set"));
|
||||
assert!(rules.is_non_sink_receiver_type("set[int]"));
|
||||
assert!(rules.is_non_sink_receiver_type("dict[str, int]"));
|
||||
assert!(rules.is_non_sink_receiver_type("list[str]"));
|
||||
assert!(rules.is_non_sink_receiver_type("defaultdict"));
|
||||
assert!(rules.is_non_sink_receiver_type("Counter"));
|
||||
assert!(rules.is_non_sink_receiver_type("OrderedDict"));
|
||||
// Negative: arbitrary type names must not match.
|
||||
assert!(!rules.is_non_sink_receiver_type("Project"));
|
||||
assert!(!rules.is_non_sink_receiver_type("QuerySet"));
|
||||
|
||||
// Bare-callee constructor form: `set()`, `dict()`,
|
||||
// `defaultdict()`, `Counter()`.
|
||||
assert!(rules.is_non_sink_constructor_callee("set"));
|
||||
assert!(rules.is_non_sink_constructor_callee("dict"));
|
||||
assert!(rules.is_non_sink_constructor_callee("list"));
|
||||
assert!(rules.is_non_sink_constructor_callee("frozenset"));
|
||||
assert!(rules.is_non_sink_constructor_callee("defaultdict"));
|
||||
assert!(rules.is_non_sink_constructor_callee("Counter"));
|
||||
// Negative: bare callees that are NOT non-sink types must not
|
||||
// be treated as constructors. `update`, `filter`, `find` are
|
||||
// verb names, not container types.
|
||||
assert!(!rules.is_non_sink_constructor_callee("update"));
|
||||
assert!(!rules.is_non_sink_constructor_callee("filter"));
|
||||
assert!(!rules.is_non_sink_constructor_callee("find"));
|
||||
assert!(!rules.is_non_sink_constructor_callee("Project"));
|
||||
|
||||
// End-to-end classification: `verified_ids.update(..)` with
|
||||
// `verified_ids` registered as a non-sink var classifies as
|
||||
// `InMemoryLocal`, the precondition for suppressing the
|
||||
// false `DbMutation` finding.
|
||||
let mut non_sink_vars: HashSet<String> = HashSet::new();
|
||||
non_sink_vars.insert("verified_ids".to_string());
|
||||
non_sink_vars.insert("requested_teams".to_string());
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("verified_ids.update", &non_sink_vars),
|
||||
Some(SinkClass::InMemoryLocal)
|
||||
);
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("requested_teams.add", &non_sink_vars),
|
||||
Some(SinkClass::InMemoryLocal)
|
||||
);
|
||||
// Recall guard: a real ORM mutation on the same verb still
|
||||
// classifies as `DbMutation` when the receiver is qualified.
|
||||
let empty: HashSet<String> = HashSet::new();
|
||||
assert_eq!(
|
||||
rules.classify_sink_class("Project.objects.update", &empty),
|
||||
Some(SinkClass::DbMutation)
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-language recall guard: only Python populates the new
|
||||
/// container types by default. Other-language defaults must
|
||||
/// not inadvertently inherit `set` / `dict` / `list` as non-sink
|
||||
/// types via the merge path (those names overlap with verb
|
||||
/// indicators in those languages).
|
||||
#[test]
|
||||
fn python_container_types_do_not_leak_to_other_languages() {
|
||||
let cfg = Config::default();
|
||||
for lang in ["javascript", "typescript", "go", "java", "ruby", "rust"] {
|
||||
let rules = build_auth_rules(&cfg, lang);
|
||||
assert!(
|
||||
!rules.is_non_sink_receiver_type("set"),
|
||||
"lang={lang} unexpectedly recognises bare `set` as non-sink type",
|
||||
);
|
||||
assert!(
|
||||
!rules.is_non_sink_receiver_type("dict"),
|
||||
"lang={lang} unexpectedly recognises bare `dict` as non-sink type",
|
||||
);
|
||||
assert!(
|
||||
!rules.is_non_sink_receiver_type("list"),
|
||||
"lang={lang} unexpectedly recognises bare `list` as non-sink type",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `require_<resource>_<role>` structural recogniser for project
|
||||
/// helpers like `require_trip_member`, `require_doc_owner`.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ pub mod checks;
|
|||
pub mod config;
|
||||
pub mod extract;
|
||||
pub mod model;
|
||||
pub mod router_facts;
|
||||
pub mod sql_semantics;
|
||||
|
||||
use crate::commands::scan::Diag;
|
||||
|
|
@ -102,21 +103,98 @@ pub fn run_auth_analysis(
|
|||
if !rules.enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut model = extract::extract_authorization_model(
|
||||
// Resolve cross-file router-deps for the active file (Python only)
|
||||
// before constructing the model, so the FlaskExtractor sees the
|
||||
// full per-file dep map at extraction time. See `router_facts`
|
||||
// module + `analyse_file_fused` for the wider pipeline.
|
||||
let cross_file_router_deps =
|
||||
resolve_cross_file_router_deps_for_file(lang, file_path, global_summaries);
|
||||
let model = extract::extract_authorization_model(
|
||||
lang,
|
||||
cfg.framework_ctx.as_ref(),
|
||||
tree,
|
||||
source,
|
||||
file_path,
|
||||
&rules,
|
||||
cross_file_router_deps.as_ref(),
|
||||
);
|
||||
run_auth_analysis_with_model(
|
||||
model,
|
||||
tree,
|
||||
lang,
|
||||
file_path,
|
||||
&rules,
|
||||
var_types,
|
||||
global_summaries,
|
||||
scan_root,
|
||||
)
|
||||
}
|
||||
|
||||
/// Look up `GlobalSummaries.router_facts_by_module` and resolve the
|
||||
/// cross-file router-deps map for the file at `file_path`. Returns
|
||||
/// `None` for non-Python files, files whose module_id has no matching
|
||||
/// `<parent>.include_router(<this_file>.<var>, ...)` edges anywhere in
|
||||
/// the project, or callers that don't pass `global_summaries`.
|
||||
pub(crate) fn resolve_cross_file_router_deps_for_file(
|
||||
lang: &str,
|
||||
file_path: &Path,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
) -> Option<HashMap<String, Vec<(model::CallSite, bool)>>> {
|
||||
if lang != "python" {
|
||||
return None;
|
||||
}
|
||||
let gs = global_summaries?;
|
||||
let module_id = router_facts::module_id_for_path(file_path)?;
|
||||
let resolved = gs.resolve_cross_file_router_deps(&module_id);
|
||||
if resolved.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant of [`run_auth_analysis`] that accepts a pre-built
|
||||
/// [`model::AuthorizationModel`] instead of building one from the AST.
|
||||
///
|
||||
/// Lets callers that need both diagnostics AND
|
||||
/// `(FuncKey, AuthCheckSummary)` per-file summaries (the fused pass-2
|
||||
/// path in [`crate::ast::analyse_file_fused`]) construct the base
|
||||
/// authorization model exactly once and route both consumers through
|
||||
/// it. Pre-fix the fused path called
|
||||
/// [`extract::extract_authorization_model`] twice per file (once via
|
||||
/// [`run_auth_analysis`], once via [`extract_auth_summaries_by_key`]),
|
||||
/// duplicating the AST walks for `collect_top_level_units` +
|
||||
/// `build_function_unit_with_meta` + `collect_unit_state` + every
|
||||
/// extractor's framework-detection scan. On the
|
||||
/// `mattermost/server/channels/app` profile that double-extract
|
||||
/// accounted for 35.3% of total wall-clock; sharing the base model
|
||||
/// drops it to ~17.6%.
|
||||
///
|
||||
/// The mutations applied here (`apply_var_types_to_model`,
|
||||
/// `apply_typed_bounded_params`, `apply_helper_lifting`) only
|
||||
/// affect diagnostic emission — `extract_auth_summaries_from_model`
|
||||
/// reads the **base** model so callers must extract summaries before
|
||||
/// passing the model in.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_auth_analysis_with_model(
|
||||
mut model: model::AuthorizationModel,
|
||||
tree: &Tree,
|
||||
lang: &str,
|
||||
file_path: &Path,
|
||||
rules: &config::AuthAnalysisRules,
|
||||
var_types: Option<&VarTypes>,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
scan_root: Option<&Path>,
|
||||
) -> Vec<Diag> {
|
||||
if !rules.enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Refine `SensitiveOperation::sink_class` using SSA-derived
|
||||
// variable types. Runs only when the caller supplied `var_types`
|
||||
// (skipped for slug-lookup / unit-test call sites).
|
||||
if let Some(types) = var_types {
|
||||
apply_var_types_to_model(&mut model, &rules, types);
|
||||
apply_var_types_to_model(&mut model, rules, types);
|
||||
apply_typed_bounded_params(&mut model, types);
|
||||
}
|
||||
|
||||
|
|
@ -128,11 +206,16 @@ pub fn run_auth_analysis(
|
|||
// (when provided) for cross-file helpers that live in other files.
|
||||
apply_helper_lifting(&mut model, lang, file_path, scan_root, global_summaries);
|
||||
|
||||
// Phase 1 caller-scope IPA: propagate route-handler-level auth
|
||||
// checks DOWN to callee helper units within the same file. See
|
||||
// [`apply_caller_scope_propagation`] for the propagation rule.
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
if model.routes.is_empty() && model.units.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
checks::run_checks(&model, &rules)
|
||||
checks::run_checks(&model, rules)
|
||||
.into_iter()
|
||||
.map(|finding| auth_finding_to_diag(&finding, tree, file_path))
|
||||
.collect()
|
||||
|
|
@ -167,8 +250,28 @@ pub fn extract_auth_summaries_by_key(
|
|||
source,
|
||||
file_path,
|
||||
&rules,
|
||||
None,
|
||||
);
|
||||
summaries_keyed_by_func(&model, lang, file_path, scan_root)
|
||||
extract_auth_summaries_from_model(&model, lang, file_path, scan_root)
|
||||
}
|
||||
|
||||
/// Variant of [`extract_auth_summaries_by_key`] that consumes a
|
||||
/// pre-built [`model::AuthorizationModel`].
|
||||
///
|
||||
/// Designed for callers that also need to run the diagnostic pipeline
|
||||
/// (which mutates the model via [`run_auth_analysis_with_model`]):
|
||||
/// extract summaries first against the base model, then hand the same
|
||||
/// model to the diag pipeline so the second
|
||||
/// [`extract::extract_authorization_model`] AST walk per file is
|
||||
/// avoided. See [`run_auth_analysis_with_model`] for the wider
|
||||
/// rationale and measured saving.
|
||||
pub fn extract_auth_summaries_from_model(
|
||||
model: &model::AuthorizationModel,
|
||||
lang: &str,
|
||||
file_path: &Path,
|
||||
scan_root: Option<&Path>,
|
||||
) -> Vec<(FuncKey, model::AuthCheckSummary)> {
|
||||
summaries_keyed_by_func(model, lang, file_path, scan_root)
|
||||
}
|
||||
|
||||
/// Convert an already-built [`model::AuthorizationModel`] into a
|
||||
|
|
@ -444,6 +547,203 @@ fn apply_helper_lifting(
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 1 caller-scope IPA: propagate route-handler-level auth checks
|
||||
/// DOWN to callee helper units within the same file.
|
||||
///
|
||||
/// `apply_helper_lifting` walks UPWARD: a helper that internally
|
||||
/// proves ownership / membership / etc. has its summary lifted onto
|
||||
/// each call site in the caller. But the inverse direction —
|
||||
/// route handler that authenticates via route-level decorator/
|
||||
/// dependency, then delegates to a private helper that performs the
|
||||
/// actual sink — is the dominant FP shape on FastAPI / Django / Flask
|
||||
/// codebases (sentry, saleor, airflow): the helper has no inline
|
||||
/// auth_checks of its own, so `check_ownership_gaps` flags every
|
||||
/// `session.add(...)` / `Model.objects.filter(id=...)` it contains.
|
||||
///
|
||||
/// This pass closes that gap inside a single file. For each helper
|
||||
/// unit, if **every** same-file caller (across the whole call graph)
|
||||
/// is itself an authorized route handler (route-level non-Login auth
|
||||
/// check) or has already been authorized via this same propagation
|
||||
/// in a prior round, lift the caller's route-level checks onto the
|
||||
/// helper. Iterated to a small fixpoint so transitive helper chains
|
||||
/// `route → mid_helper → leaf_helper` are also covered.
|
||||
///
|
||||
/// Synthetic checks carry `is_route_level=true` so
|
||||
/// `auth_check_covers_subject` short-circuits coverage for any
|
||||
/// subject the helper sees, mirroring the in-handler decorator-lift
|
||||
/// semantics established by [`extract::flask::inject_middleware_auth`].
|
||||
///
|
||||
/// **Soundness rule**: a helper's `unit_callers` list must be
|
||||
/// non-empty AND every caller must be authorized. This refuses to
|
||||
/// authorize:
|
||||
/// * helpers with no in-file caller (dead code or external
|
||||
/// entry-point — could be CLI, cron, test harness, …),
|
||||
/// * helpers called from a mix of authorized routes and unauthorized
|
||||
/// callers (the unauthorized path is the real FP attack surface),
|
||||
/// * helpers called only from another un-lifted helper (no
|
||||
/// evidence the upstream chain authenticates).
|
||||
///
|
||||
/// Cross-file caller-scope IPA — where the route handler lives in
|
||||
/// file A and the helper in file B — is not yet implemented.
|
||||
/// Requires plumbing per-file caller auth checks through
|
||||
/// `GlobalSummaries`, not just the existing per-callee
|
||||
/// `AuthCheckSummary`. See `deep_engine_fixes.md` for the deferred
|
||||
/// follow-up.
|
||||
fn apply_caller_scope_propagation(model: &mut model::AuthorizationModel) {
|
||||
use model::{AnalysisUnitKind, AuthCheck, AuthCheckKind};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// Build leaf-name → unit_idx map. Only non-route-handler units are
|
||||
// lift TARGETS; route handlers don't need downward lift since they
|
||||
// already carry their own route-level auth.
|
||||
let mut leaf_to_unit: HashMap<String, usize> = HashMap::new();
|
||||
for (idx, unit) in model.units.iter().enumerate() {
|
||||
if unit.kind == AnalysisUnitKind::RouteHandler {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = unit.name.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let leaf = name.rsplit('.').next().unwrap_or(name);
|
||||
if leaf.is_empty() {
|
||||
continue;
|
||||
}
|
||||
leaf_to_unit.entry(leaf.to_string()).or_insert(idx);
|
||||
}
|
||||
|
||||
// For each callee unit, collect its same-file caller indices.
|
||||
// Iterates every unit's `call_sites` once; a callee with no
|
||||
// matching unit (calls into stdlib, framework, third-party) gets
|
||||
// an empty `unit_callers[i]` and is excluded from propagation
|
||||
// below.
|
||||
let mut unit_callers: Vec<Vec<usize>> = vec![Vec::new(); model.units.len()];
|
||||
for (caller_idx, unit) in model.units.iter().enumerate() {
|
||||
let mut seen_callees: HashSet<usize> = HashSet::new();
|
||||
for call in &unit.call_sites {
|
||||
let leaf = call.name.rsplit('.').next().unwrap_or(&call.name);
|
||||
if let Some(&callee_idx) = leaf_to_unit.get(leaf)
|
||||
&& callee_idx != caller_idx
|
||||
&& seen_callees.insert(callee_idx)
|
||||
{
|
||||
unit_callers[callee_idx].push(caller_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed `authorized` only when a unit carries at least one
|
||||
// route-level Other / Membership / Ownership / AdminGuard check.
|
||||
// `LoginGuard` alone proves only identity, not authority, and
|
||||
// `TokenExpiry` / `TokenRecipient` alone don't justify
|
||||
// foreign-id mutations — `has_prior_subject_auth` already filters
|
||||
// those kinds out. Seeding on those would silently authorize
|
||||
// helpers reachable from a login-only route.
|
||||
let is_seed_kind = |k: AuthCheckKind| {
|
||||
!matches!(
|
||||
k,
|
||||
AuthCheckKind::LoginGuard | AuthCheckKind::TokenExpiry | AuthCheckKind::TokenRecipient
|
||||
)
|
||||
};
|
||||
let mut authorized: HashSet<usize> = (0..model.units.len())
|
||||
.filter(|i| {
|
||||
model.units[*i]
|
||||
.auth_checks
|
||||
.iter()
|
||||
.any(|c| c.is_route_level && is_seed_kind(c.kind))
|
||||
})
|
||||
.collect();
|
||||
// Lift ALL route-level non-Login auth checks once a unit is
|
||||
// authorized, including `TokenExpiry` / `TokenRecipient`. Those
|
||||
// kinds are required by `check_token_override_without_validation`
|
||||
// (which gates separately from `has_prior_subject_auth`); without
|
||||
// them the callee fires `token_override_without_validation` even
|
||||
// after `missing_ownership_check` is suppressed. `LoginGuard` is
|
||||
// still excluded — it's too weak to count as a coverage proof for
|
||||
// either downstream check.
|
||||
let unit_route_level_checks: Vec<Vec<AuthCheck>> = model
|
||||
.units
|
||||
.iter()
|
||||
.map(|unit| {
|
||||
unit.auth_checks
|
||||
.iter()
|
||||
.filter(|c| c.is_route_level && c.kind != AuthCheckKind::LoginGuard)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Per-callee aggregated lift checks, populated as we authorize.
|
||||
// Stored separately so we can apply mutations after the fixpoint
|
||||
// loop without invalidating immutable borrows above.
|
||||
let mut helper_lift: HashMap<usize, Vec<AuthCheck>> = HashMap::new();
|
||||
|
||||
const MAX_ROUNDS: usize = 4;
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let mut grew = false;
|
||||
for (callee_idx, callers) in unit_callers.iter().enumerate().take(model.units.len()) {
|
||||
if authorized.contains(&callee_idx) {
|
||||
continue;
|
||||
}
|
||||
if callers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !callers.iter().all(|c| authorized.contains(c)) {
|
||||
continue;
|
||||
}
|
||||
// Aggregate the route-level checks from every authorized
|
||||
// caller. Non-route-handler callers contribute nothing
|
||||
// (their `unit_route_level_checks[c]` is empty by
|
||||
// construction) — only route handlers up the chain seed
|
||||
// real route-level checks, and downstream helpers
|
||||
// propagate those forward via the `is_route_level=true`
|
||||
// flag on the synthetic checks.
|
||||
let mut chosen: Vec<AuthCheck> = Vec::new();
|
||||
for &caller_idx in callers {
|
||||
for check in &unit_route_level_checks[caller_idx] {
|
||||
chosen.push(check.clone());
|
||||
}
|
||||
if let Some(prior) = helper_lift.get(&caller_idx) {
|
||||
for check in prior {
|
||||
chosen.push(check.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen.is_empty() {
|
||||
continue;
|
||||
}
|
||||
authorized.insert(callee_idx);
|
||||
helper_lift.insert(callee_idx, chosen);
|
||||
grew = true;
|
||||
}
|
||||
if !grew {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (callee_idx, checks) in helper_lift {
|
||||
let unit = &mut model.units[callee_idx];
|
||||
let mut existing_keys: HashSet<((usize, usize), AuthCheckKind, String)> = unit
|
||||
.auth_checks
|
||||
.iter()
|
||||
.map(|c| (c.span, c.kind, c.callee.clone()))
|
||||
.collect();
|
||||
for check in checks {
|
||||
let mut synth = check;
|
||||
// Re-anchor at the callee's start line so the
|
||||
// `check.line <= op.line` gate in `has_prior_subject_auth`
|
||||
// covers every operation inside the callee. Without this
|
||||
// re-anchor, the synthetic check carries the caller's line
|
||||
// (which is greater than the callee's body lines) and
|
||||
// doesn't gate any of the callee's sinks.
|
||||
synth.line = unit.line;
|
||||
synth.callee = format!("(caller-scope lift {})", synth.callee);
|
||||
let key = (synth.span, synth.kind, synth.callee.clone());
|
||||
if existing_keys.insert(key) {
|
||||
unit.auth_checks.push(synth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `name → AuthCheckSummary` map by walking each unit's auth
|
||||
/// checks and recording, for every check subject whose value-ref name
|
||||
/// matches a positional parameter name of the unit, that param index
|
||||
|
|
@ -742,11 +1042,14 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{VarTypes, apply_var_types_to_model, receiver_root, sink_class_for_type};
|
||||
use super::{
|
||||
VarTypes, apply_caller_scope_propagation, apply_var_types_to_model, receiver_root,
|
||||
sink_class_for_type,
|
||||
};
|
||||
use crate::auth_analysis::config::build_auth_rules;
|
||||
use crate::auth_analysis::model::{
|
||||
AnalysisUnit, AnalysisUnitKind, AuthorizationModel, OperationKind, SensitiveOperation,
|
||||
SinkClass,
|
||||
AnalysisUnit, AnalysisUnitKind, AuthCheck, AuthCheckKind, AuthorizationModel, CallSite,
|
||||
OperationKind, SensitiveOperation, SinkClass,
|
||||
};
|
||||
use crate::ssa::type_facts::TypeKind;
|
||||
use crate::utils::config::Config;
|
||||
|
|
@ -868,6 +1171,239 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Build a synthetic [`AnalysisUnit`] with the given kind, name,
|
||||
/// and call_site leaf names. No operations or auth_checks; tests
|
||||
/// add those explicitly.
|
||||
fn unit_with_calls(kind: AnalysisUnitKind, name: &str, callees: &[&str]) -> AnalysisUnit {
|
||||
AnalysisUnit {
|
||||
kind,
|
||||
name: Some(name.into()),
|
||||
span: (0, 0),
|
||||
params: Vec::new(),
|
||||
context_inputs: Vec::new(),
|
||||
call_sites: callees
|
||||
.iter()
|
||||
.map(|c| CallSite {
|
||||
name: (*c).to_string(),
|
||||
args: Vec::new(),
|
||||
span: (0, 0),
|
||||
args_value_refs: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
auth_checks: Vec::new(),
|
||||
operations: Vec::new(),
|
||||
value_refs: Vec::new(),
|
||||
condition_texts: Vec::new(),
|
||||
line: 1,
|
||||
row_field_vars: HashMap::new(),
|
||||
var_alias_chain: HashMap::new(),
|
||||
row_population_data: HashMap::new(),
|
||||
self_actor_vars: HashSet::new(),
|
||||
self_actor_id_vars: HashSet::new(),
|
||||
authorized_sql_vars: HashSet::new(),
|
||||
const_bound_vars: HashSet::new(),
|
||||
typed_bounded_vars: HashSet::new(),
|
||||
typed_bounded_dto_fields: HashMap::new(),
|
||||
self_scoped_session_bases: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn route_level_check(kind: AuthCheckKind) -> AuthCheck {
|
||||
AuthCheck {
|
||||
kind,
|
||||
callee: "Security(require_auth)".into(),
|
||||
subjects: Vec::new(),
|
||||
span: (10, 11),
|
||||
line: 1,
|
||||
args: Vec::new(),
|
||||
condition_text: None,
|
||||
is_route_level: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_lifts_route_level_other_to_callee_helper() {
|
||||
// Mirrors the airflow shape:
|
||||
// route handler `ti_update_state` carries route-level Other
|
||||
// (from scoped Security dep), calls `_create_state_update`
|
||||
// (helper); helper's body sinks should inherit the lift.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let mut handler = unit_with_calls(
|
||||
AnalysisUnitKind::RouteHandler,
|
||||
"ti_update_state",
|
||||
&["_create_state_update"],
|
||||
);
|
||||
handler
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::Other));
|
||||
handler
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::TokenExpiry));
|
||||
handler
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::TokenRecipient));
|
||||
let helper = unit_with_calls(AnalysisUnitKind::Function, "_create_state_update", &[]);
|
||||
model.units.push(handler);
|
||||
model.units.push(helper);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
// Helper now has 3 lifted auth checks (Other + TokenExpiry +
|
||||
// TokenRecipient), each with `is_route_level=true` and line
|
||||
// re-anchored to helper's start line.
|
||||
let helper = &model.units[1];
|
||||
let kinds: HashSet<AuthCheckKind> = helper.auth_checks.iter().map(|c| c.kind).collect();
|
||||
assert!(
|
||||
kinds.contains(&AuthCheckKind::Other),
|
||||
"helper should inherit Other check from caller"
|
||||
);
|
||||
assert!(
|
||||
kinds.contains(&AuthCheckKind::TokenExpiry),
|
||||
"helper should inherit TokenExpiry check (needed for token_override suppression)"
|
||||
);
|
||||
assert!(
|
||||
kinds.contains(&AuthCheckKind::TokenRecipient),
|
||||
"helper should inherit TokenRecipient check"
|
||||
);
|
||||
assert!(
|
||||
helper.auth_checks.iter().all(|c| c.is_route_level),
|
||||
"lifted checks must keep is_route_level=true"
|
||||
);
|
||||
assert!(
|
||||
helper.auth_checks.iter().all(|c| c.line == helper.line),
|
||||
"lifted check.line must match callee unit start so check.line <= op.line holds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_refuses_when_helper_has_unauthorized_caller() {
|
||||
// Helper is called from BOTH an authorized route handler AND
|
||||
// a bare (no-auth) route handler. Soundness rule: if any
|
||||
// caller is unauthorized, do NOT propagate — the unauthorized
|
||||
// path is the real attack surface.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let mut authed = unit_with_calls(
|
||||
AnalysisUnitKind::RouteHandler,
|
||||
"ti_update_state",
|
||||
&["_create_state_update"],
|
||||
);
|
||||
authed
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::Other));
|
||||
let bare = unit_with_calls(
|
||||
AnalysisUnitKind::RouteHandler,
|
||||
"ti_overwrite_state",
|
||||
&["_create_state_update"],
|
||||
);
|
||||
let helper = unit_with_calls(AnalysisUnitKind::Function, "_create_state_update", &[]);
|
||||
model.units.push(authed);
|
||||
model.units.push(bare);
|
||||
model.units.push(helper);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
let helper = &model.units[2];
|
||||
assert!(
|
||||
helper.auth_checks.is_empty(),
|
||||
"helper must not be authorized when one caller has no route-level auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_refuses_when_helper_has_no_callers() {
|
||||
// Dead helper — no in-file caller. Could be invoked via CLI
|
||||
// / test / cron / external import. Stay conservative.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let helper = unit_with_calls(AnalysisUnitKind::Function, "_orphan_helper", &[]);
|
||||
model.units.push(helper);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
let helper = &model.units[0];
|
||||
assert!(
|
||||
helper.auth_checks.is_empty(),
|
||||
"helper with no in-file callers must not be authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_transitive_chain_route_to_mid_to_leaf() {
|
||||
// route → mid_helper → leaf_helper. Both helpers should be
|
||||
// authorized in two BFS rounds: round 1 lifts onto mid, round
|
||||
// 2 sees mid as authorized and lifts onto leaf.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let mut handler = unit_with_calls(
|
||||
AnalysisUnitKind::RouteHandler,
|
||||
"ti_update_state",
|
||||
&["_mid_helper"],
|
||||
);
|
||||
handler
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::Other));
|
||||
let mid = unit_with_calls(AnalysisUnitKind::Function, "_mid_helper", &["_leaf_helper"]);
|
||||
let leaf = unit_with_calls(AnalysisUnitKind::Function, "_leaf_helper", &[]);
|
||||
model.units.push(handler);
|
||||
model.units.push(mid);
|
||||
model.units.push(leaf);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
let mid_kinds: HashSet<AuthCheckKind> =
|
||||
model.units[1].auth_checks.iter().map(|c| c.kind).collect();
|
||||
let leaf_kinds: HashSet<AuthCheckKind> =
|
||||
model.units[2].auth_checks.iter().map(|c| c.kind).collect();
|
||||
assert!(
|
||||
mid_kinds.contains(&AuthCheckKind::Other),
|
||||
"mid helper should be authorized in round 1"
|
||||
);
|
||||
assert!(
|
||||
leaf_kinds.contains(&AuthCheckKind::Other),
|
||||
"leaf helper should be authorized in round 2 via the lifted mid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_does_not_seed_on_loginguard_only_route() {
|
||||
// Route handler with ONLY a LoginGuard route-level check.
|
||||
// LoginGuard alone proves identity, not authority — must not
|
||||
// seed the helper.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let mut handler =
|
||||
unit_with_calls(AnalysisUnitKind::RouteHandler, "list_things", &["_helper"]);
|
||||
handler
|
||||
.auth_checks
|
||||
.push(route_level_check(AuthCheckKind::LoginGuard));
|
||||
let helper = unit_with_calls(AnalysisUnitKind::Function, "_helper", &[]);
|
||||
model.units.push(handler);
|
||||
model.units.push(helper);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
let helper = &model.units[1];
|
||||
assert!(
|
||||
helper.auth_checks.is_empty(),
|
||||
"LoginGuard alone must not seed the helper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_scope_propagation_skips_self_recursive_call() {
|
||||
// Recursive helper that calls itself. The self-edge is
|
||||
// skipped in `unit_callers` construction so the helper has
|
||||
// zero in-file callers and stays unauthorized.
|
||||
let mut model = AuthorizationModel::default();
|
||||
let helper = unit_with_calls(AnalysisUnitKind::Function, "recurse", &["recurse"]);
|
||||
model.units.push(helper);
|
||||
|
||||
apply_caller_scope_propagation(&mut model);
|
||||
|
||||
let helper = &model.units[0];
|
||||
assert!(
|
||||
helper.auth_checks.is_empty(),
|
||||
"self-recursive helper with no other callers must not be authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_var_types_leaves_classification_untouched_when_receiver_unknown() {
|
||||
let cfg = Config::default();
|
||||
|
|
|
|||
|
|
@ -367,6 +367,17 @@ pub struct AuthorizationModel {
|
|||
/// of the framework-request-name allow-list. Empty string when no
|
||||
/// language was supplied (single-file unit-test paths).
|
||||
pub lang: String,
|
||||
/// Cross-file router-dependency lift, keyed by **local** router
|
||||
/// variable name. Pre-populated by the orchestrator before
|
||||
/// extractors run, sourced from `GlobalSummaries.router_facts_by_module`
|
||||
/// for every project file whose `<parent>.include_router(<this_file>.<var>)`
|
||||
/// edge targets a router in the current file. FlaskExtractor merges
|
||||
/// these in alongside locally-declared `dependencies=[...]` so routes
|
||||
/// attached to a bare child router still inherit the parent's
|
||||
/// `Security(...)` / `Depends(...)` deps. Empty when no cross-file
|
||||
/// resolution applies (most files) or when global summaries are not
|
||||
/// available (unit-test / single-file scan paths).
|
||||
pub cross_file_router_deps: HashMap<String, Vec<(CallSite, bool)>>,
|
||||
}
|
||||
|
||||
impl AuthorizationModel {
|
||||
|
|
|
|||
516
src/auth_analysis/router_facts.rs
Normal file
516
src/auth_analysis/router_facts.rs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
//! Cross-file FastAPI router-dependency tracking.
|
||||
//!
|
||||
//! FastAPI propagates `dependencies=[Security(...), Depends(...)]` declared
|
||||
//! at the router level onto every route attached to that router, including
|
||||
//! routes attached via cross-file `<parent>.include_router(<child>.router)`
|
||||
//! lifts. The per-file router-dep collector in
|
||||
//! `crate::auth_analysis::extract::flask::collect_router_level_dependencies`
|
||||
//! sees only the file under analysis, so a bare child router whose auth is
|
||||
//! declared on a parent router in `__init__.py` (canonical airflow shape) has
|
||||
//! no visible deps. This module captures the cross-file edges + parent
|
||||
//! declarations during pass 1 and resolves them into a per-child effective
|
||||
//! dep map for pass 2's auth analysis.
|
||||
//!
|
||||
//! Storage shape: per-Python-file [`PerFileRouterFacts`] with
|
||||
//! `local_router_deps` (the `<router> = X(deps=[…])` declarations
|
||||
//! visible in the file) and `include_router_edges` (the
|
||||
//! `<parent>.include_router(<child_module>.<child_var>, …)` calls).
|
||||
//! Persisted into `crate::summary::GlobalSummaries::router_facts_by_module`
|
||||
//! during pass 1 and resolved into the active file's
|
||||
//! [`crate::auth_analysis::model::AuthorizationModel::cross_file_router_deps`]
|
||||
//! at pass 2 entry.
|
||||
//!
|
||||
//! Module identity: file basename without `.py`. This is approximate (two
|
||||
//! files named `task_instances.py` in different packages would collide) but
|
||||
//! covers airflow-style codebases where include_router targets reference the
|
||||
//! child's module name directly (`task_instances.router`). Transitive lifts
|
||||
//! (`grandparent.include_router(parent); parent.include_router(child)`) are
|
||||
//! resolved by walking the index iteratively at lookup time.
|
||||
|
||||
use crate::auth_analysis::extract::common::{
|
||||
call_site_from_node, named_children, string_literal_value, text,
|
||||
};
|
||||
use crate::auth_analysis::model::CallSite;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// Per-file extracted router declarations + include_router edges.
|
||||
/// Persisted into `GlobalSummaries.router_facts_by_module` keyed by the
|
||||
/// file's [`module_id_for_path`]. Single-purpose: drives the cross-file
|
||||
/// router-dep resolution at pass 2 entry.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PerFileRouterFacts {
|
||||
/// Local router var → declared inline `dependencies=[...]` deps.
|
||||
/// Mirrors `flask::collect_router_level_dependencies` output.
|
||||
pub local_router_deps: HashMap<String, Vec<(CallSite, bool)>>,
|
||||
/// `<parent>.include_router(<child_module>.<child_var>, ...)` edges
|
||||
/// observed in this file. Each edge specifies a parent router var
|
||||
/// (local to this file) and a child router identified by its
|
||||
/// module_id + var name. Cross-file lookups walk these.
|
||||
pub include_router_edges: Vec<RouterIncludeEdge>,
|
||||
}
|
||||
|
||||
/// A single `<parent>.include_router(<child_module>.<child_var>, ...)`
|
||||
/// edge. `parent_var` is the local variable that owns the deps to lift;
|
||||
/// `child_module_id` + `child_var` together name the child router whose
|
||||
/// routes inherit the parent's deps.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RouterIncludeEdge {
|
||||
pub parent_var: String,
|
||||
pub child_module_id: String,
|
||||
pub child_var: String,
|
||||
}
|
||||
|
||||
/// Translate a file path into a stable cross-file module identifier.
|
||||
///
|
||||
/// Currently the file's basename without the `.py` extension — sufficient
|
||||
/// for the airflow shape (`from . import task_instances; …
|
||||
/// authenticated_router.include_router(task_instances.router)`) where the
|
||||
/// include_router target's module reference is the child file's own
|
||||
/// basename. Returns `None` for files whose stem is `__init__`
|
||||
/// (parent files don't need to be looked up; they emit edges only) or
|
||||
/// for paths with no usable stem.
|
||||
pub fn module_id_for_path(path: &Path) -> Option<String> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
if stem.is_empty() || stem == "__init__" {
|
||||
return None;
|
||||
}
|
||||
Some(stem.to_string())
|
||||
}
|
||||
|
||||
/// Stable storage key for the per-project router-facts index.
|
||||
///
|
||||
/// Uses the file's **full filesystem path** (lossy-converted to UTF-8)
|
||||
/// because the only goal of the storage key is uniqueness across files
|
||||
/// in a single scan. Collisions on shorter forms (file basename or
|
||||
/// `<parent_dir>::__init__`) are common in real codebases — airflow
|
||||
/// alone has 17 `routes/__init__.py` files spread across providers and
|
||||
/// test trees, and any keying scheme that drops the path prefix would
|
||||
/// have one such file silently overwrite another's `include_router`
|
||||
/// edges, breaking the cross-file lift on whichever parent lost the
|
||||
/// race.
|
||||
///
|
||||
/// The lookup side ([`crate::summary::GlobalSummaries::resolve_cross_file_router_deps`])
|
||||
/// iterates every stored entry and matches child references by the
|
||||
/// **last segment** ([`module_id_for_path`]) — so duplicate-basename
|
||||
/// children still get every parent's deps accumulated, which is the
|
||||
/// FastAPI-runtime-correct behavior. Path-based storage keys plus
|
||||
/// basename-based lookup keys is the right pairing.
|
||||
pub fn module_id_for_storage(path: &Path) -> Option<String> {
|
||||
let s = path.to_string_lossy();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(s.into_owned())
|
||||
}
|
||||
|
||||
/// Extract router-level deps + include_router edges from a Python AST.
|
||||
/// Returns `None` for non-Python files; pass 1 callers must gate on the
|
||||
/// file's language slug before invoking. Empty facts (no routers and no
|
||||
/// edges) still return `Some(Default::default())` so callers can record
|
||||
/// an empty index entry without re-extracting.
|
||||
pub fn extract_router_facts_for_python(tree: &Tree, bytes: &[u8]) -> PerFileRouterFacts {
|
||||
let mut facts = PerFileRouterFacts::default();
|
||||
let root = tree.root_node();
|
||||
collect_local_router_deps(root, bytes, &mut facts.local_router_deps);
|
||||
collect_include_router_edges(root, bytes, &mut facts.include_router_edges);
|
||||
facts
|
||||
}
|
||||
|
||||
/// Walk the module root for top-level `<id> = <RouterCtor>(..., dependencies=[…])`
|
||||
/// assignments, mirroring
|
||||
/// [`crate::auth_analysis::extract::flask::collect_router_level_dependencies`].
|
||||
/// Reimplemented here to avoid an inter-module Visibility tangle and
|
||||
/// to keep this module self-contained — the router extractor is the
|
||||
/// single source of truth at FlaskExtractor::extract time, this module
|
||||
/// is a parallel collection path that runs in pass 1.
|
||||
fn collect_local_router_deps(
|
||||
root: Node<'_>,
|
||||
bytes: &[u8],
|
||||
out: &mut HashMap<String, Vec<(CallSite, bool)>>,
|
||||
) {
|
||||
for child in named_children(root) {
|
||||
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;
|
||||
}
|
||||
out.entry(var_name).or_insert(deps);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every call expression in the file looking for
|
||||
/// `<parent>.include_router(<child_module>.<child_var>, ...)` shapes.
|
||||
/// Records `(parent_var, child_module_id, child_var)` for each. Skips
|
||||
/// edges where the child reference is a bare identifier (no module
|
||||
/// segment) — those would require Python import resolution to attach
|
||||
/// to a specific file, beyond this single-hop basename matching.
|
||||
fn collect_include_router_edges(root: Node<'_>, bytes: &[u8], out: &mut Vec<RouterIncludeEdge>) {
|
||||
walk_for_include_router(root, bytes, out);
|
||||
}
|
||||
|
||||
fn walk_for_include_router(node: Node<'_>, bytes: &[u8], out: &mut Vec<RouterIncludeEdge>) {
|
||||
if node.kind() == "call"
|
||||
&& let Some(edge) = parse_include_router_call(node, bytes)
|
||||
{
|
||||
out.push(edge);
|
||||
}
|
||||
for child in named_children(node) {
|
||||
walk_for_include_router(child, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_include_router_call(node: Node<'_>, bytes: &[u8]) -> Option<RouterIncludeEdge> {
|
||||
let function = node.child_by_field_name("function")?;
|
||||
if function.kind() != "attribute" {
|
||||
return None;
|
||||
}
|
||||
let attr = function.child_by_field_name("attribute")?;
|
||||
if text(attr, bytes) != "include_router" {
|
||||
return None;
|
||||
}
|
||||
let object = function.child_by_field_name("object")?;
|
||||
let parent_var = match object.kind() {
|
||||
"identifier" => text(object, bytes).trim().to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
if parent_var.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let arguments = node.child_by_field_name("arguments")?;
|
||||
// First positional arg (skip keyword_argument children).
|
||||
let first = named_children(arguments)
|
||||
.into_iter()
|
||||
.find(|child| child.kind() != "keyword_argument")?;
|
||||
if first.kind() != "attribute" {
|
||||
return None;
|
||||
}
|
||||
let child_attr = first.child_by_field_name("attribute")?;
|
||||
let child_var = text(child_attr, bytes).trim().to_string();
|
||||
if child_var.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let child_object = first.child_by_field_name("object")?;
|
||||
// Use the **last segment** of a possibly-dotted module reference as
|
||||
// the cross-file module id. `task_instances.router` →
|
||||
// module_id="task_instances"; `pkg.task_instances.router` →
|
||||
// module_id="task_instances" (last attribute segment).
|
||||
let child_module_id = match child_object.kind() {
|
||||
"identifier" => text(child_object, bytes).trim().to_string(),
|
||||
"attribute" => {
|
||||
let inner_attr = child_object.child_by_field_name("attribute")?;
|
||||
text(inner_attr, bytes).trim().to_string()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if child_module_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(RouterIncludeEdge {
|
||||
parent_var,
|
||||
child_module_id,
|
||||
child_var,
|
||||
})
|
||||
}
|
||||
|
||||
fn keyword_argument_value<'tree>(
|
||||
arguments: Node<'tree>,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
) -> Option<Node<'tree>> {
|
||||
for arg in named_children(arguments) {
|
||||
if arg.kind() != "keyword_argument" {
|
||||
continue;
|
||||
}
|
||||
let key = arg.child_by_field_name("name")?;
|
||||
if text(key, bytes) == name {
|
||||
return arg.child_by_field_name("value");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Local copy of the router-constructor recogniser (parallel to
|
||||
/// [`crate::auth_analysis::extract::flask::is_router_like_constructor`]
|
||||
/// to avoid the visibility tangle).
|
||||
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;
|
||||
}
|
||||
if tail.len() > "Router".len()
|
||||
&& tail.ends_with("Router")
|
||||
&& tail.chars().next().is_some_and(|c| c.is_ascii_uppercase())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Cross-file dep-marker unwrapper. Differs from the in-file
|
||||
/// [`crate::auth_analysis::extract::flask::unwrap_depends_call`] in
|
||||
/// the *scoped-security* gating policy:
|
||||
///
|
||||
/// * **In-file** (per-route or per-router declarations visible to
|
||||
/// the active file's FlaskExtractor): only `Security(callable,
|
||||
/// scopes=[non-empty])` flips `scoped_security = true`. A bare
|
||||
/// `Security(callable)` stays as a LoginGuard — conservative because
|
||||
/// per-route bare Security is often used for login-only deps.
|
||||
///
|
||||
/// * **Cross-file via `include_router`** (this function, persisted
|
||||
/// into the project-wide router-facts index for the cross-file lift):
|
||||
/// ANY `Security(...)` marker at the parent-router level flips
|
||||
/// `scoped_security = true`, regardless of explicit `scopes=[...]`.
|
||||
/// Rationale: the FastAPI architectural pattern
|
||||
/// `parent_router = APIRouter(dependencies=[Security(callable)])`
|
||||
/// followed by `parent_router.include_router(child_router, ...)` is
|
||||
/// structurally a declaration that **every route under the child
|
||||
/// router is auth-protected**. Treating it as authorization (Other
|
||||
/// AuthCheckKind, via the existing `inject_middleware_auth` scoped
|
||||
/// promotion) is semantically correct — the developer's `Security`
|
||||
/// marker placement IS the authorization signal. Bare `Depends(...)`
|
||||
/// at the parent-router level is NOT promoted (it's a generic dep,
|
||||
/// often a login fetcher).
|
||||
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_dep_marker_callee(&function_text) {
|
||||
return None;
|
||||
}
|
||||
let is_security = is_security_marker(&function_text);
|
||||
let arguments = node.child_by_field_name("arguments")?;
|
||||
let children = named_children(arguments);
|
||||
let first = children
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|child| child.kind() != "keyword_argument")?;
|
||||
// Cross-file scoped policy: any Security marker at parent-router
|
||||
// level → scoped=true. See doc comment above for rationale.
|
||||
let scoped_security = is_security;
|
||||
let _ = string_literal_value;
|
||||
let _ = keyword_argument_value;
|
||||
match first.kind() {
|
||||
"call" => Some((call_site_from_node(first, bytes), scoped_security)),
|
||||
"identifier" | "attribute" | "scoped_identifier" => {
|
||||
Some((call_site_from_node(first, bytes), scoped_security))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_security_marker(callee: &str) -> bool {
|
||||
let trimmed = callee.trim();
|
||||
matches!(
|
||||
trimmed,
|
||||
"Security" | "fastapi.Security" | "fastapi.params.Security"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tree_sitter::Parser;
|
||||
|
||||
fn parse_python(source: &str) -> 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")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_id_for_path_strips_py_extension() {
|
||||
assert_eq!(
|
||||
module_id_for_path(Path::new("/x/y/task_instances.py")),
|
||||
Some("task_instances".into())
|
||||
);
|
||||
// `__init__` returns None — parent files are storage-only, not
|
||||
// lookup keys.
|
||||
assert_eq!(module_id_for_path(Path::new("/x/y/__init__.py")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_id_for_storage_uses_full_path_to_avoid_basename_collisions() {
|
||||
// Different `routes/__init__.py` files in different packages
|
||||
// must produce DIFFERENT keys — basename / parent-dir keying
|
||||
// would collide on real codebases (airflow alone has 17
|
||||
// `routes/__init__.py` files across its provider tree).
|
||||
let a = module_id_for_storage(Path::new(
|
||||
"/x/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py",
|
||||
))
|
||||
.unwrap();
|
||||
let b = module_id_for_storage(Path::new(
|
||||
"/x/airflow-core/src/airflow/api_fastapi/core_api/routes/__init__.py",
|
||||
))
|
||||
.unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
/// Canonical airflow shape — `routes/__init__.py` declares
|
||||
/// `authenticated_router = VersionedAPIRouter(dependencies=[Security(require_auth)])`
|
||||
/// and lifts every per-file child router via `include_router(...)`.
|
||||
/// Pass 1 must capture both the parent's local deps and the edges
|
||||
/// targeting `task_instances.router`. Cross-file Security wrappers
|
||||
/// (regardless of explicit `scopes=[...]`) are flagged scoped — the
|
||||
/// architectural intent of
|
||||
/// `parent_router = X(dependencies=[Security(callable)])` followed by
|
||||
/// `parent_router.include_router(child_router)` is auth scoping over
|
||||
/// every child route. See the `unwrap_depends_call` doc comment for
|
||||
/// the policy rationale.
|
||||
#[test]
|
||||
fn extract_router_facts_captures_parent_and_edges() {
|
||||
let src = "from cadwyn import VersionedAPIRouter\n\
|
||||
from fastapi import APIRouter, Security\n\
|
||||
from . import task_instances, dag_runs\n\
|
||||
from .security import require_auth\n\
|
||||
\n\
|
||||
execution_api_router = APIRouter()\n\
|
||||
authenticated_router = VersionedAPIRouter(dependencies=[Security(require_auth)])\n\
|
||||
\n\
|
||||
authenticated_router.include_router(task_instances.router, prefix=\"/task-instances\")\n\
|
||||
authenticated_router.include_router(dag_runs.router, prefix=\"/dag-runs\")\n\
|
||||
execution_api_router.include_router(authenticated_router)\n";
|
||||
let tree = parse_python(src);
|
||||
let bytes = src.as_bytes();
|
||||
let facts = extract_router_facts_for_python(&tree, bytes);
|
||||
|
||||
let parent_deps = facts
|
||||
.local_router_deps
|
||||
.get("authenticated_router")
|
||||
.expect("authenticated_router deps captured");
|
||||
assert_eq!(parent_deps.len(), 1);
|
||||
let (site, scoped) = &parent_deps[0];
|
||||
assert_eq!(site.name, "require_auth");
|
||||
assert!(
|
||||
*scoped,
|
||||
"cross-file: any Security marker is scoped-equivalent"
|
||||
);
|
||||
|
||||
// execution_api_router has no deps → no entry.
|
||||
assert!(!facts.local_router_deps.contains_key("execution_api_router"));
|
||||
|
||||
// Two child include_router edges + one nested
|
||||
// execution_api_router.include_router(authenticated_router) edge.
|
||||
assert!(facts.include_router_edges.iter().any(|e| {
|
||||
e.parent_var == "authenticated_router"
|
||||
&& e.child_module_id == "task_instances"
|
||||
&& e.child_var == "router"
|
||||
}));
|
||||
assert!(facts.include_router_edges.iter().any(|e| {
|
||||
e.parent_var == "authenticated_router"
|
||||
&& e.child_module_id == "dag_runs"
|
||||
&& e.child_var == "router"
|
||||
}));
|
||||
}
|
||||
|
||||
/// `<parent>.include_router(<bare_var>)` — child reference is a bare
|
||||
/// identifier, no module segment. Cannot resolve to a specific
|
||||
/// file, so no edge is emitted. This includes the canonical
|
||||
/// `execution_api_router.include_router(authenticated_router)` chain
|
||||
/// where the child is a sibling router declared in the same file —
|
||||
/// transitive in-file lifts are handled by the local-deps map, not
|
||||
/// the cross-file edge list.
|
||||
#[test]
|
||||
fn extract_router_facts_skips_bare_identifier_child_refs() {
|
||||
let src = "outer = APIRouter()\nouter.include_router(authenticated_router)\n";
|
||||
let tree = parse_python(src);
|
||||
let bytes = src.as_bytes();
|
||||
let facts = extract_router_facts_for_python(&tree, bytes);
|
||||
assert!(facts.include_router_edges.is_empty());
|
||||
}
|
||||
|
||||
/// Scoped Security at the parent level (real-world airflow
|
||||
/// `ti_id_router` flavor). The `scoped` flag must round-trip.
|
||||
#[test]
|
||||
fn extract_router_facts_picks_up_scoped_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 facts = extract_router_facts_for_python(&tree, bytes);
|
||||
let deps = facts
|
||||
.local_router_deps
|
||||
.get("ti_id_router")
|
||||
.expect("ti_id_router deps captured");
|
||||
let (_site, scoped) = &deps[0];
|
||||
assert!(*scoped, "scopes=[\"ti:self\"] must mark scoped");
|
||||
}
|
||||
|
||||
/// Cross-file `Depends(callable)` at parent-router level is NOT
|
||||
/// scoped — the policy promotes only Security markers (which
|
||||
/// signal authorization intent), not generic Depends (which are
|
||||
/// often login fetchers). Bare `Depends(get_current_user)` lifted
|
||||
/// onto a child router via `include_router` stays as a LoginGuard
|
||||
/// on the child's per-route auth checks.
|
||||
#[test]
|
||||
fn extract_router_facts_does_not_promote_depends() {
|
||||
let src = "from fastapi import APIRouter, Depends\n\
|
||||
v1 = APIRouter(dependencies=[Depends(get_current_user)])\n";
|
||||
let tree = parse_python(src);
|
||||
let bytes = src.as_bytes();
|
||||
let facts = extract_router_facts_for_python(&tree, bytes);
|
||||
let deps = facts.local_router_deps.get("v1").expect("v1 deps captured");
|
||||
let (_site, scoped) = &deps[0];
|
||||
assert!(!*scoped, "Depends never scoped-security at cross-file lift");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue