Critical bug fixes and recall improvements (#68)

This commit is contained in:
Eli Peter 2026-05-11 12:42:39 -04:00 committed by GitHub
parent 7d0e7320e2
commit 55247b7fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
352 changed files with 60069 additions and 900 deletions

View file

@ -90,6 +90,13 @@ fn check_ownership_gaps(
if op.sink_class.is_some_and(|c| !c.is_auth_relevant()) {
continue;
}
// NextAuth callbacks are themselves the authentication
// boundary, both reads and mutations inside them operate on
// identity context, so suppress regardless of op kind.
// Other auth helpers stay read-only-suppressed.
if is_nextauth_callback_unit(unit) {
continue;
}
if op.kind == OperationKind::Read && unit_is_auth_helper(unit) {
continue;
}
@ -105,6 +112,40 @@ fn check_ownership_gaps(
if is_delegated_read_with_actor_context(unit, op, &relevant_subjects) {
continue;
}
// Owner-equality scoping: when the same call composes a
// foreign-id subject with an actor-context subject (e.g.
// `db.findFirst({where: {id: input.id, userId: ctx.user.id}})`
// in a TRPC handler), the actor pin tenant-scopes the
// query to the authenticated user. The relevant_subjects
// filter has already excluded actor-context entries; if
// the unfiltered op.subjects still carries an
// actor-context subject, the missing co-binding is the
// owner-eq witness.
//
// `is_actor_context_subject` is constrained: it only
// accepts subjects whose base is in
// `is_self_scoped_session_base` (`req.user`,
// `ctx.session.user`, etc.) OR in the per-unit
// `self_scoped_session_bases` set populated by the
// typed-extractor pre-pass (TRPC alias matches,
// NextAuth callback formals). Generic `user.id` /
// `me.id` does not qualify, so unrelated co-occurrences
// do not over-suppress.
//
// Trade-off: a privesc-via-`data` shape like
// `db.update({where: {id: input.id}, data: {ownerId: ctx.user.id}})`
// would also be suppressed because both subjects appear
// at the call site without arg-position info. That
// pattern is rare and would need its own rule. The
// owner-eq common case removes ~70 cal.com FPs and
// matches the canonical Express / TRPC scoping idiom.
let has_actor_co_subject = op
.subjects
.iter()
.any(|s| is_actor_context_subject(s, unit));
if has_actor_co_subject {
continue;
}
if !has_prior_subject_auth(unit, op, &relevant_subjects) {
findings.push(AuthFinding {
rule_id: rules.rule_id("missing_ownership_check"),
@ -879,7 +920,7 @@ fn unit_is_auth_helper(unit: &AnalysisUnit) -> bool {
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect();
(normalized.starts_with("has")
if (normalized.starts_with("has")
|| normalized.starts_with("check")
|| normalized.starts_with("require")
|| normalized.starts_with("verify")
@ -891,6 +932,62 @@ fn unit_is_auth_helper(unit: &AnalysisUnit) -> bool {
|| normalized.contains("access")
|| normalized.contains("permission")
|| normalized.contains("authoriz"))
{
return true;
}
is_nextauth_callback_unit(unit)
}
/// True when this unit IS, or LEXICALLY CONTAINS, a NextAuth
/// (next-auth) callback definition.
///
/// Two shapes are recognised:
/// * A unit whose name is `signIn` / `session` / `jwt` / `redirect` /
/// `authorize` / `authorized` AND whose destructured params include
/// a canonical NextAuth formal (`user` / `token` / `account` /
/// `profile` / `credentials` / `session` / `trigger`). Matches the
/// flat `export const authOptions = { callbacks: { ... } }` shape
/// where the top-level unit-creation pass walks into the object
/// literal and produces one unit per method.
/// * A unit whose body contains an object literal with a
/// `callbacks: { ... }` property naming at least one NextAuth
/// callback (set by `body_returns_nextauth_options` at extract
/// time). Matches the `export const getOptions = (...) =>
/// ({ callbacks: { ... } })` shape where the inner callback
/// methods do not become their own units — operations from their
/// bodies get accumulated under the outer arrow's unit, so the
/// outer unit's name (`getOptions`) is the only handle the
/// suppressor can latch onto.
///
/// NextAuth callbacks ARE the authentication boundary; operations on
/// `user.id` / `existingUser.id` inside them resolve the authenticated
/// identity, they do not look up a tenant-scoped resource based on
/// untrusted input.
fn is_nextauth_callback_unit(unit: &AnalysisUnit) -> bool {
if unit.is_nextauth_options_factory {
return true;
}
let Some(name) = unit.name.as_deref() else {
return false;
};
if !matches!(
name,
"signIn" | "session" | "jwt" | "redirect" | "authorize" | "authorized"
) {
return false;
}
const SIGNAL_PARAMS: &[&str] = &[
"user",
"token",
"account",
"profile",
"credentials",
"session",
"trigger",
];
unit.params
.iter()
.any(|p| SIGNAL_PARAMS.contains(&p.as_str()))
}
fn is_delegated_read_with_actor_context(
@ -1118,6 +1215,7 @@ mod tests {
typed_bounded_vars: HashSet::new(),
typed_bounded_dto_fields: HashMap::new(),
self_scoped_session_bases: HashSet::new(),
is_nextauth_options_factory: false,
}
}

View file

@ -712,6 +712,8 @@ pub fn build_function_unit_with_meta(
.cloned()
.collect();
let is_nextauth_options_factory = body_returns_nextauth_options(node, bytes);
AnalysisUnit {
kind,
name,
@ -734,9 +736,207 @@ pub fn build_function_unit_with_meta(
typed_bounded_vars: preseeded_bounded,
typed_bounded_dto_fields: std::collections::HashMap::new(),
self_scoped_session_bases: state.self_scoped_session_bases,
is_nextauth_options_factory,
}
}
/// True when the function body at `node` is a NextAuth authority
/// surface. Recognises two shapes:
///
/// 1. An object literal with a `callbacks: { ... }` property whose
/// nested entries name at least one canonical NextAuth callback
/// (`signIn`, `session`, `jwt`, `redirect`, `authorize`,
/// `authorized`). Matches the cal.com idiom
/// `export const getOptions = (...) => ({ callbacks: { ... } })`.
///
/// 2. An object literal whose entries name at least one distinctive
/// NextAuth Adapter method (`getUserByAccount`, `linkAccount`,
/// `unlinkAccount`, `createVerificationToken`,
/// `useVerificationToken`, `getSessionAndUser`) AND at least one
/// other canonical Adapter method. Matches the cal.com idiom
/// `function CalComAdapter(prisma): Adapter { return { ... } }`
/// where the returned Adapter object holds the implementation.
///
/// In both shapes the inner method bodies are NOT enumerated as
/// separate units (object method shorthands stay anonymous), so every
/// identity-resolution operation from the inner methods accumulates
/// onto the outer factory's unit. Without this flag the outer unit's
/// name is `getOptions` / `CalComAdapter`, so `is_nextauth_callback_unit`
/// cannot match by name and the missing-ownership rule fires on every
/// identity lookup inside the surface.
///
/// JS/TS-only by construction (matches `object` / `pair` /
/// `method_definition` / `shorthand_property_identifier` node kinds).
/// Returns false on other languages.
fn body_returns_nextauth_options(node: Node<'_>, bytes: &[u8]) -> bool {
fn scan(node: Node<'_>, bytes: &[u8]) -> bool {
if matches!(node.kind(), "object" | "object_expression")
&& (object_has_nextauth_callbacks_property(node, bytes)
|| object_is_nextauth_adapter(node, bytes))
{
return true;
}
for child in named_children(node) {
if scan(child, bytes) {
return true;
}
}
false
}
scan(node, bytes)
}
fn object_has_nextauth_callbacks_property(node: Node<'_>, bytes: &[u8]) -> bool {
for entry in named_children(node) {
let Some((key_text, value_node)) = object_entry_key_value(entry, bytes) else {
continue;
};
if key_text != "callbacks" {
continue;
}
if matches!(value_node.kind(), "object" | "object_expression")
&& object_contains_nextauth_callback_method(value_node, bytes)
{
return true;
}
}
false
}
fn object_contains_nextauth_callback_method(node: Node<'_>, bytes: &[u8]) -> bool {
for entry in named_children(node) {
if entry.kind() == "method_definition" {
if let Some(name_node) = entry.child_by_field_name("name") {
let name = text(name_node, bytes);
if is_nextauth_callback_name(&name) {
return true;
}
}
continue;
}
if let Some((key_text, _value_node)) = object_entry_key_value(entry, bytes)
&& is_nextauth_callback_name(&key_text)
{
return true;
}
}
false
}
fn object_entry_key_value<'a>(entry: Node<'a>, bytes: &[u8]) -> Option<(String, Node<'a>)> {
match entry.kind() {
"pair" => {
let key = entry.child_by_field_name("key")?;
let value = entry.child_by_field_name("value")?;
Some((object_key_text(key, bytes), value))
}
"method_definition" => {
let name = entry.child_by_field_name("name")?;
Some((text(name, bytes), entry))
}
_ => None,
}
}
fn object_key_text(node: Node<'_>, bytes: &[u8]) -> String {
match node.kind() {
"property_identifier" | "identifier" | "shorthand_property_identifier" => text(node, bytes),
"string" | "string_literal" => {
let raw = text(node, bytes);
raw.trim_matches(|c| c == '"' || c == '\'' || c == '`')
.to_string()
}
"computed_property_name" => {
if let Some(inner) = node.named_child(0) {
object_key_text(inner, bytes)
} else {
String::new()
}
}
_ => text(node, bytes),
}
}
fn is_nextauth_callback_name(name: &str) -> bool {
matches!(
name,
"signIn" | "session" | "jwt" | "redirect" | "authorize" | "authorized"
)
}
/// True when the object literal at `node` looks like a NextAuth
/// Adapter implementation: at least one distinctive Adapter method
/// name AND at least two canonical Adapter method names overall.
/// The distinctive subset (`getUserByAccount`, `linkAccount`,
/// `unlinkAccount`, `createVerificationToken`, `useVerificationToken`,
/// `getSessionAndUser`) names operations that are unique to the
/// NextAuth Adapter contract; the broader canonical set (createUser /
/// getUser / getUserByEmail / updateUser / deleteUser / createSession /
/// updateSession / deleteSession) overlaps with generic CRUD repos, so
/// the distinctive-name witness gates the recognition.
fn object_is_nextauth_adapter(node: Node<'_>, bytes: &[u8]) -> bool {
let mut distinctive_seen = false;
let mut total = 0_usize;
for entry in named_children(node) {
let Some(key_text) = adapter_object_entry_key(entry, bytes) else {
continue;
};
if !is_nextauth_adapter_method_name(&key_text) {
continue;
}
total += 1;
if is_nextauth_adapter_distinctive_method_name(&key_text) {
distinctive_seen = true;
}
}
distinctive_seen && total >= 2
}
fn adapter_object_entry_key(entry: Node<'_>, bytes: &[u8]) -> Option<String> {
match entry.kind() {
"method_definition" => entry
.child_by_field_name("name")
.map(|n| object_key_text(n, bytes)),
"pair" => entry
.child_by_field_name("key")
.map(|n| object_key_text(n, bytes)),
"shorthand_property_identifier" => Some(text(entry, bytes)),
_ => None,
}
}
fn is_nextauth_adapter_method_name(name: &str) -> bool {
matches!(
name,
"createUser"
| "getUser"
| "getUserByEmail"
| "getUserByAccount"
| "updateUser"
| "deleteUser"
| "linkAccount"
| "unlinkAccount"
| "createSession"
| "getSessionAndUser"
| "updateSession"
| "deleteSession"
| "createVerificationToken"
| "useVerificationToken"
)
}
fn is_nextauth_adapter_distinctive_method_name(name: &str) -> bool {
matches!(
name,
"getUserByAccount"
| "linkAccount"
| "unlinkAccount"
| "createVerificationToken"
| "useVerificationToken"
| "getSessionAndUser"
)
}
#[derive(Default)]
struct UnitState {
call_sites: Vec<CallSite>,
@ -832,14 +1032,13 @@ fn collect_unit_state(
"call_expression" | "call" | "method_invocation" | "method_call_expression" => {
collect_call(node, bytes, rules, state)
}
"if_statement" | "elif_clause" | "while_statement" | "do_statement" | "if" | "unless"
| "if_modifier" | "unless_modifier" | "while_modifier" | "until_modifier"
| "while_expression" => {
"while_statement" | "do_statement" | "while_modifier" | "until_modifier"
| "while_expression" | "unless" | "unless_modifier" => {
if let Some(condition) = node.child_by_field_name("condition") {
collect_condition(condition, bytes, rules, state);
}
}
"if_expression" => {
"if_statement" | "elif_clause" | "if_expression" | "if" | "if_modifier" => {
if let Some(condition) = node.child_by_field_name("condition") {
collect_condition(condition, bytes, rules, state);
}
@ -868,6 +1067,12 @@ fn collect_unit_state(
collect_self_actor_binding(node, bytes, rules, state);
collect_self_actor_id_binding(node, bytes, state);
collect_const_string_binding(node, bytes, state);
// JS/TS row-fetch declarators (`const webhook = await
// repo.findById(id)`) need row-population recognition so
// the post-fetch ownership-equality detector can attribute
// back to the row's let line. `collect_row_population`
// accepts the `name` field used by `variable_declarator`.
collect_row_population(node, bytes, state);
}
// Go `id := "id"` / Python `id = "id"` / Java `String id = "id";` /
// Ruby `id = "id"`, language-specific binding nodes that the
@ -1336,11 +1541,13 @@ fn collect_member_alias_binding(node: Node<'_>, bytes: &[u8], state: &mut UnitSt
/// flagged despite a textual auth check on the resulting row.
fn collect_row_population(node: Node<'_>, bytes: &[u8], state: &mut UnitState) {
// Most languages expose `pattern`/`value` on let / const / var
// declarations. Ruby `assignment` uses `left`/`right` instead, so
// accept either. When both fields are missing, the node isn't an
// RHS-bound binding and we skip.
// declarations. Ruby `assignment` uses `left`/`right` instead.
// JS/TS `variable_declarator` uses `name`/`value`. Accept any of
// them; when none is present the node isn't an RHS-bound binding
// and we skip.
let Some(pattern) = node
.child_by_field_name("pattern")
.or_else(|| node.child_by_field_name("name"))
.or_else(|| node.child_by_field_name("left"))
else {
return;
@ -2784,8 +2991,8 @@ fn detect_ownership_equality_check(if_node: Node<'_>, bytes: &[u8], state: &mut
let Some(operator) = binary_operator_text(condition, bytes) else {
return;
};
let is_ne = matches!(operator.as_str(), "!=" | "ne");
let is_eq = matches!(operator.as_str(), "==" | "eq");
let is_ne = matches!(operator.as_str(), "!=" | "!==" | "ne");
let is_eq = matches!(operator.as_str(), "==" | "===" | "eq");
if !is_ne && !is_eq {
return;
}
@ -2801,7 +3008,7 @@ fn detect_ownership_equality_check(if_node: Node<'_>, bytes: &[u8], state: &mut
return;
};
if !branch_has_early_exit(fail_branch) {
if !branch_has_early_exit(fail_branch, bytes) {
return;
}
@ -2925,18 +3132,63 @@ fn resolve_else_block(alt: Node<'_>) -> Node<'_> {
alt
}
fn branch_has_early_exit(branch: Node<'_>) -> bool {
named_children(branch).into_iter().any(node_is_early_exit)
fn branch_has_early_exit(branch: Node<'_>, bytes: &[u8]) -> bool {
named_children(branch)
.into_iter()
.any(|n| node_is_early_exit(n, bytes))
}
fn node_is_early_exit(node: Node<'_>) -> bool {
fn node_is_early_exit(node: Node<'_>, bytes: &[u8]) -> bool {
match node.kind() {
"return_expression" | "return_statement" => true,
"expression_statement" => named_children(node).into_iter().any(node_is_early_exit),
// Throwing aborts execution flow. Common in JS/TS / Java
// (`throw new ForbiddenException()`), Python (`raise ...`),
// Ruby (`raise ...`).
"throw_statement" | "throw_expression" | "raise_statement" => true,
// A call whose callee name is in the framework denial set
// (`notFound()` / `redirect()` / `abort()` / `forbidden()` /
// `unauthorized()` / etc.) terminates the request. These
// helpers either throw under the hood (Next.js, Flask) or
// exit the process (`process.exit`, `sys.exit`).
"call_expression" | "call" | "method_invocation" => is_denial_call(node, bytes),
"expression_statement" => named_children(node)
.into_iter()
.any(|n| node_is_early_exit(n, bytes)),
_ => false,
}
}
/// Recognise calls that act as request-terminating denial helpers.
///
/// The callee name is matched against a curated set of framework
/// idioms. This is read in `node_is_early_exit` from inside the
/// row-ownership-equality detector, where the ambient context already
/// requires an `owner.field` vs. `self.id` binary comparison; the
/// denial-call match is only the early-exit witness, not the auth
/// signal itself.
fn is_denial_call(call_node: Node<'_>, bytes: &[u8]) -> bool {
let Some(callee_node) = call_node
.child_by_field_name("function")
.or_else(|| call_node.child_by_field_name("name"))
else {
return false;
};
let callee_text = text(callee_node, bytes);
let trimmed = callee_text.trim();
let leaf = trimmed.rsplit('.').next().unwrap_or(trimmed);
let leaf = leaf.rsplit("::").next().unwrap_or(leaf);
matches!(
leaf,
"notFound"
| "redirect"
| "permanentRedirect"
| "unauthorized"
| "forbidden"
| "abort"
| "halt"
)
}
pub(super) fn is_owner_field_subject(subject: &ValueRef) -> bool {
let raw = match subject.source_kind {
ValueSourceKind::ArrayIndex => subject.base.as_deref().unwrap_or(&subject.name),
@ -5419,4 +5671,220 @@ mod tests {
));
}
}
#[test]
fn trpc_options_destructure_param_seeds_self_scoped_session_base() {
// Cal.com-shaped TRPC handler: parameter is a destructured
// options alias whose `ctx` field's nested type literal
// references `TrpcSessionUser`. `FileMeta::scan` adds
// `GetOptions` to `trpc_alias_names` (body-text marker hit);
// `collect_trpc_ctx_param` then fires on the
// `required_parameter` and seeds `ctx.user` into the unit's
// `self_scoped_session_bases`.
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(
tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
))
.unwrap();
let src = br#"
type TrpcSessionUser = { id: number };
type GetOptions = {
ctx: { user: NonNullable<TrpcSessionUser> };
input: { id: number };
};
export const handleGet = async ({ ctx, input }: GetOptions) => {
return prisma.booking.findFirst({ where: { id: input.id, userId: ctx.user.id } });
};
"#;
let tree = parser.parse(src.as_slice(), None).unwrap();
let meta = super::FileMeta::scan(tree.root_node(), src);
assert!(
meta.trpc_alias_names.contains("GetOptions"),
"trpc_alias_names missing GetOptions: {:?}",
meta.trpc_alias_names
);
let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
let mut model = crate::auth_analysis::model::AuthorizationModel::default();
super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
let unit = model
.units
.iter()
.find(|u| u.name.as_deref() == Some("handleGet"))
.expect("handleGet unit");
assert!(
unit.self_scoped_session_bases.contains("ctx.user"),
"self_scoped_session_bases missing ctx.user: {:?}",
unit.self_scoped_session_bases
);
}
/// Pin the JS/TS post-fetch ownership-equality recogniser added in
/// session 0011. The `if_statement` arm of `collect_unit_state`
/// must dispatch to `detect_ownership_equality_check` (previously
/// only `if_expression` did), the strict `!==` operator must be
/// recognised as inequality, the framework denial helper
/// `notFound()` must count as an early-exit witness, and the JS/TS
/// `variable_declarator` arm must populate `row_population_data`
/// so the synthetic `Ownership` AuthCheck attributes back to the
/// row's let line.
#[test]
fn detect_post_fetch_ownership_jsts_with_strict_neq_and_denial_call() {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(
tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
))
.unwrap();
let src = br#"
declare class Repo { findById(id: string): Promise<{ userId: number }>; }
declare function getServerSession(): Promise<{ user?: { id: number } } | null>;
declare function notFound(): never;
export async function handleGet({ id }: { id: string }) {
const session = await getServerSession();
if (!session?.user?.id) return null;
const repo: Repo = new Repo();
const webhook = await repo.findById(id);
if (webhook.userId !== session.user.id) {
notFound();
}
return webhook;
}
"#;
let tree = parser.parse(src.as_slice(), None).unwrap();
let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
let mut model = crate::auth_analysis::model::AuthorizationModel::default();
super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
let unit = model
.units
.iter()
.find(|u| u.name.as_deref() == Some("handleGet"))
.expect("handleGet unit");
let webhook_pop = unit
.row_population_data
.get("webhook")
.expect("collect_row_population must populate `webhook` from variable_declarator");
// The `let webhook = await repo.findById(id)` line should
// anchor at the call site, not the let line. In this fixture
// both are on the same line so the back-dating is invisible
// here, the assertion is that the entry exists.
assert!(webhook_pop.0 > 0);
let owner_check = unit
.auth_checks
.iter()
.find(|c| matches!(c.kind, super::AuthCheckKind::Ownership))
.expect("ownership-equality detector must emit an Ownership AuthCheck");
let owner_subject = owner_check
.subjects
.iter()
.find(|s| s.field.as_deref() == Some("userId"))
.expect("Ownership AuthCheck must carry the owner field subject");
assert_eq!(
owner_subject.base.as_deref(),
Some("webhook"),
"owner subject base must be the row var: {:?}",
owner_subject
);
}
/// Pin the NextAuth Adapter factory recogniser added in session
/// 0030. `body_returns_nextauth_options` must flip on for the
/// cal.com `function CalComAdapter(client): Adapter { return {
/// createUser, getUser, getUserByAccount, ... } }` shape so that
/// `is_nextauth_callback_unit` suppresses the missing-ownership
/// rule across the inner Adapter methods (their operations
/// accumulate onto the outer factory's unit).
#[test]
fn nextauth_adapter_factory_flags_outer_unit() {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(
tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
))
.unwrap();
let src = br#"
declare const prismaClient: any;
export default function CalComAdapter(client: any) {
return {
createUser: async (data: { email: string }) => {
const user = await prismaClient.user.create({ data });
return user;
},
getUser: async (id: string) => {
const user = await prismaClient.user.findUnique({ where: { id } });
return user;
},
async getUserByAccount(providerAccountId: { provider: string; providerAccountId: string }) {
const account = await prismaClient.account.findUnique({
where: { provider_providerAccountId: providerAccountId },
select: { user: true },
});
return account?.user ?? null;
},
createVerificationToken: async (data: any) => prismaClient.verificationToken.create({ data }),
useVerificationToken: async (identifier: any) => prismaClient.verificationToken.delete({ where: identifier }),
linkAccount: async (account: any) => prismaClient.account.create({ data: account }),
unlinkAccount: async (providerAccountId: any) => prismaClient.account.delete({ where: providerAccountId }),
};
}
"#;
let tree = parser.parse(src.as_slice(), None).unwrap();
let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
let mut model = crate::auth_analysis::model::AuthorizationModel::default();
super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
let unit = model
.units
.iter()
.find(|u| u.name.as_deref() == Some("CalComAdapter"))
.expect("CalComAdapter unit");
assert!(
unit.is_nextauth_options_factory,
"Adapter factory must set is_nextauth_options_factory: \
{:?}",
unit.name
);
}
/// Negative: a generic CRUD repo with `createUser` / `getUser` /
/// `updateUser` / `deleteUser` (no Adapter-distinctive method
/// names) must NOT be flagged as a NextAuth Adapter. Without the
/// distinctive-name gate any plain user repo would suppress
/// missing-ownership findings.
#[test]
fn nextauth_adapter_recogniser_rejects_generic_crud_repo() {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter::Language::from(
tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
))
.unwrap();
let src = br#"
declare const db: any;
export function makeUserRepo() {
return {
createUser: async (data: any) => db.user.create({ data }),
getUser: async (id: string) => db.user.findUnique({ where: { id } }),
updateUser: async (id: string, data: any) => db.user.update({ where: { id }, data }),
deleteUser: async (id: string) => db.user.delete({ where: { id } }),
};
}
"#;
let tree = parser.parse(src.as_slice(), None).unwrap();
let rules = crate::auth_analysis::config::AuthAnalysisRules::disabled();
let mut model = crate::auth_analysis::model::AuthorizationModel::default();
super::collect_top_level_units(tree.root_node(), src, &rules, &mut model);
let unit = model
.units
.iter()
.find(|u| u.name.as_deref() == Some("makeUserRepo"))
.expect("makeUserRepo unit");
assert!(
!unit.is_nextauth_options_factory,
"generic CRUD repo must NOT be flagged as Adapter: {:?}",
unit.name
);
}
}

View file

@ -1090,6 +1090,7 @@ mod tests {
typed_bounded_vars: HashSet::new(),
typed_bounded_dto_fields: HashMap::new(),
self_scoped_session_bases: HashSet::new(),
is_nextauth_options_factory: false,
}
}
@ -1205,6 +1206,7 @@ mod tests {
typed_bounded_vars: HashSet::new(),
typed_bounded_dto_fields: HashMap::new(),
self_scoped_session_bases: HashSet::new(),
is_nextauth_options_factory: false,
}
}

View file

@ -282,6 +282,23 @@ pub struct AnalysisUnit {
/// destructures route through a base chain, not a top-level
/// binding.
pub self_scoped_session_bases: HashSet<String>,
/// True when this JS/TS unit is the body of a NextAuth options
/// factory: its function body contains an object literal with a
/// `callbacks: { ... }` property whose nested entries name at
/// least one NextAuth canonical callback (`signIn` / `session` /
/// `jwt` / `redirect` / `authorize` / `authorized`). Set by
/// `build_function_unit_with_meta` when the file structures the
/// options as `export const X = (...) => ({ callbacks: { ... } })`
/// (cal.com's `getOptions` shape) rather than the flat
/// `export const authOptions = { callbacks: { ... } }` shape.
/// Operations inside the inner callback bodies still get
/// accumulated under the outer factory unit (the unit-creation
/// pass does not descend into object-literal method shorthands),
/// so the outer unit is the only place the auth analyser can
/// recognise the identity-resolution context. Consulted by
/// `is_nextauth_callback_unit` so the missing-ownership check
/// suppresses operations inside the factory.
pub is_nextauth_options_factory: bool,
}
/// Per-function summary of which positional parameters are