From 6d5e45a9c73c3d860dc87fa65b5199107da34d6b Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:35:36 -0400 Subject: [PATCH 1/9] feat(rules): add go-permission-predicate-call for argless authz predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sister rule to go-has-role-call/go-permission-check-call. Those require a string-literal argument; this rule covers the very common Go shape where the call is a boolean predicate (IsAdmin(), IsOwner(), CanRead(unit), HasAnyUnitAccess()). Surfaced by the corpus shakedown on Gitea, where the entire permission model is hand-rolled around predicates of this shape and none matched any existing rule. No rego template — the role/permission isn't recoverable from the AST, so any stub would mislead. Predicate names are explicit alternations rather than a broad Has[A-Z]... to avoid duplicate findings with the existing string-literal rules. Confidence is medium. --- rules/go/permission-predicate-call.toml | 143 ++++++++++++++++++++++++ src/rules/embedded.rs | 4 + 2 files changed, 147 insertions(+) create mode 100644 rules/go/permission-predicate-call.toml diff --git a/rules/go/permission-predicate-call.toml b/rules/go/permission-predicate-call.toml new file mode 100644 index 0000000..6e01d97 --- /dev/null +++ b/rules/go/permission-predicate-call.toml @@ -0,0 +1,143 @@ +[rule] +id = "go-permission-predicate-call" +languages = ["go"] +category = "rbac" +confidence = "medium" +description = "Boolean permission predicate (e.g. IsAdmin(), IsOwner(), CanRead(unit), HasAnyUnitAccess())" +# Sister rule to `go-has-role-call` and `go-permission-check-call`. Those +# require a *string-literal* argument so the rego template can substitute +# the role/permission name. This rule covers the very common Go shape +# where the call is a boolean predicate with no string-literal argument: +# +# user.IsAdmin() +# repo.IsOwner(account) +# perm.CanRead(unit) +# perm.HasAnyUnitAccess() +# +# Surfaced by the corpus shakedown on Gitea, where the entire permission +# model is hand-rolled around predicates of this shape. None matched any +# string-literal-arg rule. +# +# No rego template: the role/permission being checked isn't recoverable +# from the AST, so any generated stub would be misleading. The finding +# itself is still valuable as a policy-decision marker. Confidence is +# `medium` because predicate-style names like `IsActive` or `HasMembers` +# can also describe non-authz state — but the anchored alternation below +# is conservative (admin/owner/access/membership/permission keywords). +# +# Predicate names are explicit alternations rather than a broad +# `Has[A-Z][A-Za-z]+` to avoid duplicate findings with the existing +# string-literal rules (which would also match `HasRole`/`HasPermission`). +query = """ +(call_expression + function: [ + (identifier) @fn_name + (selector_expression field: (field_identifier) @fn_name) + ] +) @match +""" + +[rule.predicates.fn_name] +match = "^(IsAdmin|IsOwner|Is[A-Z][A-Za-z]+Admin|Is[A-Z][A-Za-z]+Owner|Can[A-Z][A-Za-z]+|HasAnyUnitAccess|HasUnitAccess|HasAccess|HasMembership|HasOwnership|Owns[A-Z][A-Za-z]+)$" + +# -- Positive: Gitea-shape predicates -- + +[[rule.tests]] +input = """ +package main + +func handle(repo *Repository) { + if repo.IsAdmin(user) { + deleteRepo() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission) { + if perm.IsOwner() { + manage() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission) { + if perm.HasAnyUnitAccess() { + list() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission, unit Unit) { + if perm.CanRead(unit) { + read() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(repo *Repository) { + if IsRepoAdmin(repo, user) { + approve() + } +} +""" +expect_match = true + +# -- Negative: not authz-shaped -- + +[[rule.tests]] +input = """ +package main + +func handle(s *Server) { + if s.IsRunning() { + serve() + } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +package main + +func handle(c *Cache) { + if c.HasValue("foo") { + useIt() + } +} +""" +expect_match = false + +# -- Negative: covered by go-has-role-call (kept narrow to avoid duplicates) -- + +[[rule.tests]] +input = """ +package main + +func handle() { + if HasRole("admin") { + deleteAll() + } +} +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index dde9352..63622d8 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -187,6 +187,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "go-permission-check-call", include_str!("../../rules/go/permission-check-call.toml"), ), + ( + "go-permission-predicate-call", + include_str!("../../rules/go/permission-predicate-call.toml"), + ), ( "go-role-check-conditional", include_str!("../../rules/go/role-check-conditional.toml"), From 31eefe8a848629963e5714e46f17d40a723eb5ce Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:36:36 -0400 Subject: [PATCH 2/9] feat(rules): cover chained permission DSLs and Cal.com-style membership helpers Two changes from the corpus shakedown on Ghost and Cal.com: - New ts-chained-permission-call: matches the shape `(...)..(...)` used by Ghost's permission DSL (canThis(user).edit.post(post)) and by CASL/Pundit ports. Predicate is a prefix match on can/may/allow/check/ability. Medium confidence; no rego template (verb/resource are identifiers, not literals). - Widen ts-permission-check-call predicate to include belongsTo, isTeamAdmin, isOrgOwner, hasMembership, and the userIs family. Cal.com uses these as first-class authz checks but they were previously unmatched. --- rules/typescript/chained-permission-call.toml | 80 +++++++++++++++++++ rules/typescript/permission-check-call.toml | 35 +++++++- src/rules/embedded.rs | 4 + 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 rules/typescript/chained-permission-call.toml diff --git a/rules/typescript/chained-permission-call.toml b/rules/typescript/chained-permission-call.toml new file mode 100644 index 0000000..654e199 --- /dev/null +++ b/rules/typescript/chained-permission-call.toml @@ -0,0 +1,80 @@ +[rule] +id = "ts-chained-permission-call" +languages = ["typescript", "javascript"] +category = "abac" +confidence = "medium" +description = "Chained permission DSL (e.g. canThis(user).edit.post(post), CASL/Pundit-style ability checks)" +# Matches the shape `(...)..(...)` where the +# leftmost callee is a permission entry point. Surfaced by the Ghost +# permission DSL (`canThis(user).edit.post(post)`) and by CASL/Pundit +# ports that follow the same pattern. +# +# Predicate on the callee is a prefix match so common variants like +# `canThis`, `mayDo`, `allowFor`, `checkAbility` all qualify. No rego +# template: the verb/resource pair is captured as identifiers, not +# string literals — emitting a stub would require synthesizing +# `input.action`/`input.resource` and would be misleading without +# more context. Confidence is medium because the shape is distinctive +# but the callee predicate (`can`/`may`/`allow`/`check`/`ability`) is +# generic English. +query = """ +(call_expression + function: (member_expression + object: (member_expression + object: (call_expression + function: (identifier) @callee) + property: (property_identifier) @verb) + property: (property_identifier) @resource) +) @match +""" + +[rule.predicates.callee] +match = "(?i)^(can|may|allow|check|ability)([A-Z]\\w*)?$" + +# -- Positive: Ghost-style permission DSL -- + +[[rule.tests]] +input = """ +if (canThis(user).edit.post(post)) { + editPost(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (canThis(req.user).browse.users()) { + listUsers(); +} +""" +expect_match = true + +# CASL-style: `can(user).read.Article(article)` — the actual CASL DSL is +# slightly different (`ability.can('read', Article)`), but ports/wrappers +# often expose this chained shape. +[[rule.tests]] +input = """ +if (ability(user).read.article(article)) { + showArticle(); +} +""" +expect_match = true + +# -- Negative: not a chained permission DSL -- + +[[rule.tests]] +input = """ +if (api.get.user(id)) { + load(); +} +""" +expect_match = false + +# Wrong shape: only one property between the call and the final invocation. +[[rule.tests]] +input = """ +if (canThis(user).post(post)) { + edit(); +} +""" +expect_match = false diff --git a/rules/typescript/permission-check-call.toml b/rules/typescript/permission-check-call.toml index 983a3eb..70f3bf3 100644 --- a/rules/typescript/permission-check-call.toml +++ b/rules/typescript/permission-check-call.toml @@ -4,6 +4,14 @@ languages = ["typescript", "javascript"] category = "abac" confidence = "high" description = "Permission or capability check function call" +# Method name regex covers two families: +# 1. Allow-style ABAC verbs: can, hasPermission, checkPermission, isAllowed, allows, +# hasAccess, checkAccess, hasClaim. Deny-style (cannot, denies) are accepted too +# for completeness, though the rego template below is allow-shaped. +# 2. Membership/role helpers: belongsTo, isTeamAdmin, isOrgOwner, hasMembership, +# and the userIs family (e.g. userIsAdmin, userIsOwner). Surfaced by the +# Cal.com corpus shakedown — these are first-class authz checks but were +# previously unmatched. query = """ (call_expression function: (member_expression @@ -14,7 +22,7 @@ query = """ """ [rule.predicates.method] -match = "^(can|cannot|hasPermission|checkPermission|isAllowed|allows|denies|hasAccess|checkAccess|hasClaim)$" +match = "^(can|cannot|hasPermission|checkPermission|isAllowed|allows|denies|hasAccess|checkAccess|hasClaim|belongsTo|isTeamAdmin|isOrgOwner|hasMembership|userIs[A-Z][A-Za-z]*)$" [rule.rego_template] template = """ @@ -32,3 +40,28 @@ if (user.can("delete", resource)) { } """ expect_match = true + +# Cal.com-style membership/role helpers when called with a string literal. +[[rule.tests]] +input = """ +if (membership.belongsTo("team-42")) { + showTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (acl.isTeamAdmin("team-42")) { + manageTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (session.userIsOwner("org-1")) { + manageOrg(); +} +""" +expect_match = true diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 63622d8..0ab64ec 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -40,6 +40,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "permission-check-call", include_str!("../../rules/typescript/permission-check-call.toml"), ), + ( + "chained-permission-call", + include_str!("../../rules/typescript/chained-permission-call.toml"), + ), ( "session-auth-check", include_str!("../../rules/typescript/session-auth-check.toml"), From 6c68b28cc931899384d7bb1f29e5d6dd7b359de9 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:37:18 -0400 Subject: [PATCH 3/9] feat(rules): match negated role comparisons and roleName variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cal.com Next.js handlers use `if (session.user.role !== "ADMIN")` as a forbidden-guard pattern. The existing query already supported nested member access on the LHS (the rightmost property is captured), but restricted the operator to `==`/`===`. Extend operators to include `!=` and `!==`, capture the operator as @op for future use, and add `roleName` to the predicate alternation (common in NextAuth-style session shapes). Note: the rego template still emits `==`. For `!==` matches the generated stub will be operator-inverted from the original intent — the policy decision point itself is the value here; operator polarity is a small manual fix at extract time. Documented inline. --- rules/typescript/role-check-conditional.toml | 51 ++++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/rules/typescript/role-check-conditional.toml b/rules/typescript/role-check-conditional.toml index 08553f8..7b5a362 100644 --- a/rules/typescript/role-check-conditional.toml +++ b/rules/typescript/role-check-conditional.toml @@ -4,19 +4,31 @@ languages = ["typescript", "javascript"] category = "rbac" confidence = "high" description = "Direct role comparison in conditional expression" +# Matches `if (.role === "ADMIN")` and the negated variant +# `if (.role !== "ADMIN")`. The LHS is a member_expression so the +# query already accepts arbitrarily nested paths like `session.user.role` +# (Next.js/Cal.com handlers) — the property captured is the rightmost +# identifier. +# +# The captured @op is currently informational; the rego template emits an +# allow-shaped `==` regardless of operator. For `!==` matches the +# generated stub will be inverted from the original intent — flagged here +# rather than silently dropping the finding, since the policy decision +# point itself is what matters; the operator inversion is a small manual +# fix during extract. query = """ (if_statement condition: (parenthesized_expression (binary_expression left: (member_expression property: (property_identifier) @prop) - operator: ["==" "==="] + operator: ["==" "===" "!=" "!=="] @op right: (string) @role_value)) ) @match """ [rule.predicates.prop] -match = "^(role|roles|userRole|userType)$" +match = "^(role|roles|roleName|userRole|userType)$" [rule.predicates.role_value] not_match = "^[\"'](assistant|user|system|tool|function)[\"']$" @@ -38,26 +50,47 @@ if (user.role === "admin") { """ expect_match = true +# Cal.com / Next.js handler shape: nested member access on the LHS. [[rule.tests]] input = """ -if (user.name === "admin") { - greet(); +if (session.user.role === "ADMIN") { + showAdminPanel(); } """ -expect_match = false +expect_match = true +# Negated comparison: typical "block if not admin" guard. We match this +# now (per corpus shakedown — Cal.com uses `!==` extensively). [[rule.tests]] input = """ -if (msg.role === "assistant") { - processResponse(); +if (session.user.role !== "ADMIN") { + throw new ForbiddenError(); +} +""" +expect_match = true + +# `roleName` is a common variant (e.g. NextAuth session.user.roleName). +[[rule.tests]] +input = """ +if (session.user.roleName === "ADMIN") { + manage(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (user.name === "admin") { + greet(); } """ expect_match = false +# LLM message-role conversation pattern, not authz. [[rule.tests]] input = """ -if (user.role !== "banned") { - proceed(); +if (msg.role === "assistant") { + processResponse(); } """ expect_match = false From 26e5642c8f1adbd2ef416b423a9d00085d255486 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:37:59 -0400 Subject: [PATCH 4/9] feat(rules): add py-check-helper-call for Zulip-style policy helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the corpus shakedown on Zulip, where the actual policy decision points are spelled `check_can_*`, `check_basic_*`, `check_message_*access`, `check_stream_*access`, `check_*_access`, `check_*_permission`, `check_*_authz` — bare module functions invoked from views/lib code. Sister rule to py-permission-check-call (which only matches attribute calls like user.can("delete")). No rego template: the policy intent is in the function name itself. Anchored alternation requires a permission/access keyword in the suffix to avoid validation helpers like check_message_format. Medium confidence. --- rules/python/check-helper-call.toml | 87 +++++++++++++++++++++++++++++ src/rules/embedded.rs | 4 ++ 2 files changed, 91 insertions(+) create mode 100644 rules/python/check-helper-call.toml diff --git a/rules/python/check-helper-call.toml b/rules/python/check-helper-call.toml new file mode 100644 index 0000000..d3ef82b --- /dev/null +++ b/rules/python/check-helper-call.toml @@ -0,0 +1,87 @@ +[rule] +id = "py-check-helper-call" +languages = ["python"] +category = "abac" +confidence = "medium" +description = "Permission check_* helper call (e.g. check_can_invite_users(user), check_basic_stream_access(...))" +# Surfaced by the corpus shakedown on Zulip, where the actual policy +# decision points are spelled `check_can_*`, `check_basic_*`, +# `check_message_*`, `check_stream_*`, `check_*_access` — bare module +# functions invoked from views/lib code. Sister rule to +# `py-permission-check-call` (which only matches *attribute* calls like +# `user.can("delete")`). +# +# No rego template: the policy intent is encoded in the function name +# itself, not a string argument. Confidence is medium because some +# `check_*` helpers are validation rather than authz (e.g. +# `check_message_format`); the anchored alternation keeps it tight by +# requiring a permission/access keyword in the suffix. +query = """ +(call + function: (identifier) @fn_name +) @match +""" + +[rule.predicates.fn_name] +match = "^(check_can_[a-z_]+|check_basic_[a-z_]+|check_message_[a-z_]+access|check_stream_[a-z_]+access|check_[a-z_]+_access|check_[a-z_]+_permission|check_[a-z_]+_authz)$" + +# -- Positive: Zulip-shape policy helpers -- + +[[rule.tests]] +input = """ +def maybe_invite(): + check_can_invite_users(user_profile, invite_as) + invite() +""" +expect_match = true + +[[rule.tests]] +input = """ +def read_stream(stream): + check_basic_stream_access(user_profile, stream) + return stream.messages.all() +""" +expect_match = true + +[[rule.tests]] +input = """ +def edit(message): + check_message_edit_access(user_profile, message) + do_edit() +""" +expect_match = true + +[[rule.tests]] +input = """ +def view(stream_id): + check_stream_access(user_profile, stream_id) + return render() +""" +expect_match = true + +# -- Negative: validation helpers, not authz -- + +[[rule.tests]] +input = """ +def parse(payload): + check_format(payload) + return payload +""" +expect_match = false + +[[rule.tests]] +input = """ +def post(message): + check_message_format(message) + deliver() +""" +expect_match = false + +# -- Negative: covered by py-permission-check-call -- + +[[rule.tests]] +input = """ +if user.can("delete"): + delete_resource() +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 0ab64ec..8f3f6c0 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -174,6 +174,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "py-permission-check-call", include_str!("../../rules/python/permission-check-call.toml"), ), + ( + "py-check-helper-call", + include_str!("../../rules/python/check-helper-call.toml"), + ), ( "py-ownership-check", include_str!("../../rules/python/ownership-check.toml"), From 01972488433ad3d4c2097178b6757cc1b6e7bfc8 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:38:37 -0400 Subject: [PATCH 5/9] feat(rules): add java-authorized-annotation for OpenMRS-style @Authorized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenMRS's primary policy surface is `@Authorized({"Manage Users"})` applied to service methods. Surfaced by the corpus shakedown — 24 files use it; we matched zero. Two query patterns cover the single- and array-string forms; both feed the same role_value capture so the rego template emits a single privilege-check shape against input.user.privileges. --- rules/java/authorized-annotation.toml | 76 +++++++++++++++++++++++++++ src/rules/embedded.rs | 4 ++ 2 files changed, 80 insertions(+) create mode 100644 rules/java/authorized-annotation.toml diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml new file mode 100644 index 0000000..5417b22 --- /dev/null +++ b/rules/java/authorized-annotation.toml @@ -0,0 +1,76 @@ +[rule] +id = "java-authorized-annotation" +languages = ["java"] +category = "rbac" +confidence = "high" +description = "OpenMRS-style @Authorized({\"PRIV\"}) annotation" +# OpenMRS's primary policy surface is `@Authorized({"Manage Users"})` +# applied to service/method calls. Surfaced by the corpus shakedown: +# 24 files use it; before this rule we matched zero. +# +# Two query patterns cover the common forms: +# 1. Single string: @Authorized("Manage Users") +# 2. Array of strings: @Authorized({"Manage Users", "Edit Users"}) +# Both feed the same {{role_value}} capture so the rego template emits +# a single privilege-check shape. +query = """ +[ + (annotation + name: (identifier) @anno_name + arguments: (annotation_argument_list + (string_literal (string_fragment) @role_value))) + (annotation + name: (identifier) @anno_name + arguments: (annotation_argument_list + (element_value_array_initializer + (string_literal (string_fragment) @role_value)))) +] @match +""" + +[rule.predicates.anno_name] +eq = "Authorized" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + "{{role_value}}" in input.user.privileges +} +""" + +[[rule.tests]] +input = """ +public class UserService { + @Authorized("Manage Users") + public void delete(User u) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserService { + @Authorized({"Manage Users", "Edit Users"}) + public void update(User u) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserService { + @Override + public void delete(User u) { } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +public class UserService { + @Transactional + public void update(User u) { } +} +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 8f3f6c0..75330b7 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -77,6 +77,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "java-roles-allowed-array", include_str!("../../rules/java/spring-roles-allowed-array.toml"), ), + ( + "java-authorized-annotation", + include_str!("../../rules/java/authorized-annotation.toml"), + ), ( "java-spring-permit-all", include_str!("../../rules/java/spring-permit-all.toml"), From 0a75b3cbf11610438747be280d2989bca5eaf51d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:39:17 -0400 Subject: [PATCH 6/9] feat(rules): add py-require-decorator for Zulip-style @require_* gates Surfaced by the corpus shakedown on Zulip, where view functions are gated by @require_realm_admin, @require_organization_member, @require_member_or_admin, etc. Sister rule to py-login-required-decorator (which matches the exact name login_required); this matches the open-ended require_ family. Accepts both decorator forms (marker/call) as bare identifier or attribute reference, mirroring py-login-required-decorator's shape. Predicate uses a lowercase suffix to exclude HTTP method gates like @require_GET / @require_POST from django.views.decorators.http. --- rules/python/require-decorator.toml | 101 ++++++++++++++++++++++++++++ src/rules/embedded.rs | 4 ++ 2 files changed, 105 insertions(+) create mode 100644 rules/python/require-decorator.toml diff --git a/rules/python/require-decorator.toml b/rules/python/require-decorator.toml new file mode 100644 index 0000000..e8a94c4 --- /dev/null +++ b/rules/python/require-decorator.toml @@ -0,0 +1,101 @@ +[rule] +id = "py-require-decorator" +languages = ["python"] +category = "middleware" +confidence = "high" +description = "@require_* authz decorator (Zulip-style: @require_realm_admin, @require_member_or_admin, ...)" +# Surfaced by the corpus shakedown on Zulip, where view functions are +# gated by `@require_realm_admin`, `@require_organization_member`, +# `@require_member_or_admin`, etc. Sister rule to +# `py-login-required-decorator` (which matches the exact name +# `login_required`); this one matches the open-ended `require_` +# family. +# +# Accepts both decorator forms (marker and call), as bare identifier or +# attribute reference, mirroring py-login-required-decorator's shape. +query = """ +(decorator + [ + (identifier) @decorator_name + (attribute attribute: (identifier) @decorator_name) + (call + function: [ + (identifier) @decorator_name + (attribute attribute: (identifier) @decorator_name) + ]) + ] +) @match +""" + +[rule.predicates.decorator_name] +match = "^require_[a-z][a-z0-9_]*$" + +# `require_GET` / `require_POST` from django.views.decorators.http are +# HTTP method gates, not authz. The predicate's lowercase-only suffix +# already excludes them; explicit negative test below locks that in. + +[[rule.tests]] +input = """ +@require_realm_admin +def administer(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@require_member_or_admin +def view_members(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@require_organization_member(realm_id=1) +def list_streams(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@auth.require_admin +def manage(request): + pass +""" +expect_match = true + +# -- Negative: HTTP method gates and non-authz decorators -- + +[[rule.tests]] +input = """ +@require_GET +def index(request): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@require_POST +def submit(request): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@app.route('/') +def index(): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@staticmethod +def helper(): + pass +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 75330b7..35d326c 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -154,6 +154,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "py-login-required-decorator", include_str!("../../rules/python/login-required-decorator.toml"), ), + ( + "py-require-decorator", + include_str!("../../rules/python/require-decorator.toml"), + ), ( "py-django-user-passes-test", include_str!("../../rules/python/django-user-passes-test.toml"), From 92ab58a100ca3e9460aa7875d4d8423db136338d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:40:23 -0400 Subject: [PATCH 7/9] feat(rules): add ts-trpc-procedure for tRPC authz procedure builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tRPC's idiomatic authz pattern is a procedure builder (protectedProcedure, adminProcedure, authedProcedure, ...) chained into each endpoint declaration. Each chain is a distinct policy decision point — the builder injects the authz middleware into everything built from it. Surfaced by the corpus shakedown on Cal.com. Match shape fires exactly once per chain (leftmost member access whose object is a bare identifier), so multi-method chains yield a single finding. Predicate is [a-z][A-Za-z]*Procedure so well-known and project-specific builders both qualify. No rego template — the policy lives in the builder's middleware, not the call site. --- rules/typescript/trpc-procedure.toml | 87 ++++++++++++++++++++++++++++ src/rules/embedded.rs | 4 ++ 2 files changed, 91 insertions(+) create mode 100644 rules/typescript/trpc-procedure.toml diff --git a/rules/typescript/trpc-procedure.toml b/rules/typescript/trpc-procedure.toml new file mode 100644 index 0000000..37a0be7 --- /dev/null +++ b/rules/typescript/trpc-procedure.toml @@ -0,0 +1,87 @@ +[rule] +id = "ts-trpc-procedure" +languages = ["typescript", "javascript"] +category = "middleware" +confidence = "high" +description = "tRPC authz procedure builder used at a call site (protectedProcedure, adminProcedure, ...)" +# tRPC's idiomatic authz pattern is a procedure builder: +# +# export const fooRouter = createTRPCRouter({ +# bar: protectedProcedure.input(z.object({...})).query(...), +# baz: adminProcedure.mutation(...), +# }); +# +# Each `protectedProcedure.(...)` chain is a distinct policy +# decision point: the procedure builder injects the authz middleware +# (auth check, role gate) into every endpoint built from it. +# +# Surfaced by the corpus shakedown on Cal.com — this rule wraps every +# endpoint declared via an authed procedure builder. Sister rule to +# `ts-nestjs-use-guards` (same role for NestJS controllers). +# +# Match shape: `(member_expression object: (identifier) @proc_name)` +# fires exactly once per chain (the leftmost member access whose object +# is a bare identifier), so `protectedProcedure.use(mw).input(x).query(y)` +# yields a single finding rather than one per chained method. +# +# Predicate is `[a-z][A-Za-z]*Procedure` so well-known builders +# (protectedProcedure, adminProcedure, authedProcedure, orgProcedure, +# teamProcedure) and project-specific ones all qualify. Anchored to +# avoid `someUnrelatedProcedure` slipping through unintentionally — +# the suffix is distinctive enough that false positives are rare in +# practice. +# +# No rego template: the policy lives in the procedure builder's +# middleware, not at the call site, so a stub generated here would be +# misleading. The finding is the policy-decision-point marker. +query = """ +(member_expression + object: (identifier) @proc_name +) @match +""" + +[rule.predicates.proc_name] +match = "^[a-z][A-Za-z]*Procedure$" + +# -- Positive: tRPC authz builders -- + +[[rule.tests]] +input = """ +export const list = protectedProcedure + .input(z.object({ teamId: z.string() })) + .query(({ ctx, input }) => ctx.db.team.findMany()); +""" +expect_match = true + +[[rule.tests]] +input = """ +export const remove = adminProcedure.mutation(({ ctx }) => ctx.db.team.delete()); +""" +expect_match = true + +[[rule.tests]] +input = """ +export const m = authedProcedure.use(rateLimit).mutation(handler); +""" +expect_match = true + +# Custom org-scoped procedure +[[rule.tests]] +input = """ +export const x = orgProcedure.query(handler); +""" +expect_match = true + +# -- Negative: not a procedure builder -- + +[[rule.tests]] +input = """ +const result = api.user.findMany(); +""" +expect_match = false + +[[rule.tests]] +input = """ +const x = obj.method(); +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 35d326c..484f850 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -28,6 +28,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "nestjs-use-guards", include_str!("../../rules/typescript/nestjs-use-guards.toml"), ), + ( + "trpc-procedure", + include_str!("../../rules/typescript/trpc-procedure.toml"), + ), ( "nestjs-roles-decorator", include_str!("../../rules/typescript/nestjs-roles-decorator.toml"), From f4a570b26d203950dcda36ab0a97215d6d26b302 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:41:44 -0400 Subject: [PATCH 8/9] feat(rules)!: split ts-jwt-token-check into verify-decode vs sign-issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the corpus shakedown on Ghost: the report was dominated by jwt.sign(...) calls — token issuance for outbound integrations, not authz decisions. The original ts-jwt-token-check rule conflated: - verify/decode: extract claims a request will be authorized against (real authz decision point) - sign: issue a token to send outbound (crypto, not authz) Split into two rules: - ts-jwt-verify-decode: middleware category, medium confidence, drop-in replacement for the authz signal of the old rule. - ts-jwt-sign-issue: custom category, low confidence, kept (rather than dropped) because key handling / audience scoping are still relevant during a security review and users can disable a single rule by ID. BREAKING CHANGE: rule ID `ts-jwt-token-check` is removed. Users referencing it in config/filters should switch to the verify-decode or sign-issue replacements. --- rules/typescript/jwt-sign-issue.toml | 64 +++++++++++++++++++++++++ rules/typescript/jwt-token-check.toml | 39 --------------- rules/typescript/jwt-verify-decode.toml | 64 +++++++++++++++++++++++++ src/rules/embedded.rs | 8 +++- 4 files changed, 134 insertions(+), 41 deletions(-) create mode 100644 rules/typescript/jwt-sign-issue.toml delete mode 100644 rules/typescript/jwt-token-check.toml create mode 100644 rules/typescript/jwt-verify-decode.toml diff --git a/rules/typescript/jwt-sign-issue.toml b/rules/typescript/jwt-sign-issue.toml new file mode 100644 index 0000000..fc0258f --- /dev/null +++ b/rules/typescript/jwt-sign-issue.toml @@ -0,0 +1,64 @@ +[rule] +id = "ts-jwt-sign-issue" +languages = ["typescript", "javascript"] +category = "custom" +confidence = "low" +description = "JWT token issuance (jwt.sign) — typically outbound integration tokens, not an authz decision" +# Split out from the original `ts-jwt-token-check` rule. `jwt.sign(...)` +# is token *issuance*, not a policy decision: a service signing a token +# to send outbound (webhook, integration, downstream API) is doing +# crypto, not authorization. Surfaced by the corpus shakedown on Ghost, +# whose report was dominated by sign calls of this shape — deep mode +# would dismiss most. +# +# Kept as a separate rule (rather than dropped entirely) because: +# 1. Token issuance is still relevant during a security review — +# key handling, audience scoping, expiry — even if it isn't an +# authz decision point. +# 2. Users who want to suppress this noise can disable a single +# rule by ID without losing verify/decode coverage. +# +# Confidence is `low` and category is `custom` so it sits below the +# middleware/rbac/abac signal in default reports. +query = """ +(call_expression + function: (member_expression + object: (identifier) @obj + property: (property_identifier) @method) + arguments: (arguments + (_) @arg) +) @match +""" + +[rule.predicates.obj] +match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" + +[rule.predicates.method] +eq = "sign" + +[[rule.tests]] +input = """ +const token = jwt.sign({ sub: userId }, secret); +""" +expect_match = true + +[[rule.tests]] +input = """ +const t = jsonwebtoken.sign(payload, secret, { expiresIn: "1h" }); +""" +expect_match = true + +# Verify/decode belong to ts-jwt-verify-decode. +[[rule.tests]] +input = """ +const decoded = jwt.verify(token, secret); +""" +expect_match = false + +# Unrelated signer that happens to expose `.sign` — predicate on the +# object name (jwt|jsonwebtoken|jose|token|auth) keeps these out. +[[rule.tests]] +input = """ +signer.sign(httpRequest); +""" +expect_match = false diff --git a/rules/typescript/jwt-token-check.toml b/rules/typescript/jwt-token-check.toml deleted file mode 100644 index 75123c8..0000000 --- a/rules/typescript/jwt-token-check.toml +++ /dev/null @@ -1,39 +0,0 @@ -[rule] -id = "ts-jwt-token-check" -languages = ["typescript", "javascript"] -category = "middleware" -confidence = "medium" -description = "JWT token verification or authorization header access" -query = """ -(call_expression - function: (member_expression - object: (identifier) @obj - property: (property_identifier) @method) - arguments: (arguments - (_) @arg) -) @match -""" - -[rule.predicates.obj] -match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" - -[rule.predicates.method] -match = "^(verify|decode|sign)$" - -[[rule.tests]] -input = """ -const decoded = jwt.verify(token, secret); -""" -expect_match = true - -[[rule.tests]] -input = """ -signer.sign(httpRequest); -""" -expect_match = false - -[[rule.tests]] -input = """ -decoder.decode(value, { stream: true }); -""" -expect_match = false diff --git a/rules/typescript/jwt-verify-decode.toml b/rules/typescript/jwt-verify-decode.toml new file mode 100644 index 0000000..4477b5d --- /dev/null +++ b/rules/typescript/jwt-verify-decode.toml @@ -0,0 +1,64 @@ +[rule] +id = "ts-jwt-verify-decode" +languages = ["typescript", "javascript"] +category = "middleware" +confidence = "medium" +description = "JWT token verification or decode (authz decision point)" +# Split out from the original `ts-jwt-token-check` rule. The `verify` +# and `decode` family are authz decision points — they extract the +# claims the request will be authorized against. The companion rule +# `ts-jwt-sign-issue` covers `sign`, which is token *issuance* +# (typically for outbound integrations) — different category, lower +# confidence, kept separate so users can filter without losing the +# authz signal. +# +# Surfaced by the corpus shakedown on Ghost: its report was dominated +# by `jwt.sign(...)` calls — outbound token issuance, not authz +# decisions. Keeping verify/decode here at medium confidence preserves +# the authz signal while moving sign-issuance noise to its own rule. +query = """ +(call_expression + function: (member_expression + object: (identifier) @obj + property: (property_identifier) @method) + arguments: (arguments + (_) @arg) +) @match +""" + +[rule.predicates.obj] +match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" + +[rule.predicates.method] +match = "^(verify|decode)$" + +[[rule.tests]] +input = """ +const decoded = jwt.verify(token, secret); +""" +expect_match = true + +[[rule.tests]] +input = """ +const claims = jsonwebtoken.decode(token); +""" +expect_match = true + +# `sign` now belongs to ts-jwt-sign-issue. +[[rule.tests]] +input = """ +const token = jwt.sign({ sub: userId }, secret); +""" +expect_match = false + +[[rule.tests]] +input = """ +signer.sign(httpRequest); +""" +expect_match = false + +[[rule.tests]] +input = """ +decoder.decode(value, { stream: true }); +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 484f850..ead2d74 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -53,8 +53,12 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ include_str!("../../rules/typescript/session-auth-check.toml"), ), ( - "jwt-token-check", - include_str!("../../rules/typescript/jwt-token-check.toml"), + "jwt-verify-decode", + include_str!("../../rules/typescript/jwt-verify-decode.toml"), + ), + ( + "jwt-sign-issue", + include_str!("../../rules/typescript/jwt-sign-issue.toml"), ), ( "ownership-check", From 8938d6f16982723c8624db998af40ae6fc67f34a Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 4 May 2026 18:59:09 -0400 Subject: [PATCH 9/9] fix(rules): address CodeRabbit review on PR #50 Two Major findings, both fixed: 1. ts-chained-permission-call lacked a rego template. Added a stub that substitutes the captured @verb / @resource identifiers, with a TODO comment noting the captures are identifiers (not runtime values) and manual review is required. Mirrors the convention already used by java-spring-preauthorize. 2. ts-permission-check-call mixed role/membership helpers (belongsTo, isTeamAdmin, isOrgOwner, hasMembership, userIs) with permission verbs. The membership shape's literal arg is a resource identifier (e.g. "team-42"), not a permission name, so the existing template `input.action == "{{permission}}"` would emit misleading rego. Reverted the predicate widening on ts-permission-check-call and moved the membership family to a new ts-membership-check-call rule with category=rbac and a membership-shaped stub template (`"{{resource_id}}" in input.user.memberships`). Preserves the project's role-vs-permission separation. --- rules/typescript/chained-permission-call.toml | 29 ++++-- rules/typescript/membership-check-call.toml | 95 +++++++++++++++++++ rules/typescript/permission-check-call.toml | 46 +++------ src/rules/embedded.rs | 4 + 4 files changed, 133 insertions(+), 41 deletions(-) create mode 100644 rules/typescript/membership-check-call.toml diff --git a/rules/typescript/chained-permission-call.toml b/rules/typescript/chained-permission-call.toml index 654e199..daa8db9 100644 --- a/rules/typescript/chained-permission-call.toml +++ b/rules/typescript/chained-permission-call.toml @@ -10,13 +10,9 @@ description = "Chained permission DSL (e.g. canThis(user).edit.post(post), CASL/ # ports that follow the same pattern. # # Predicate on the callee is a prefix match so common variants like -# `canThis`, `mayDo`, `allowFor`, `checkAbility` all qualify. No rego -# template: the verb/resource pair is captured as identifiers, not -# string literals — emitting a stub would require synthesizing -# `input.action`/`input.resource` and would be misleading without -# more context. Confidence is medium because the shape is distinctive -# but the callee predicate (`can`/`may`/`allow`/`check`/`ability`) is -# generic English. +# `canThis`, `mayDo`, `allowFor`, `checkAbility` all qualify. +# Confidence is medium because the shape is distinctive but the callee +# predicate (`can`/`may`/`allow`/`check`/`ability`) is generic English. query = """ (call_expression function: (member_expression @@ -31,6 +27,25 @@ query = """ [rule.predicates.callee] match = "(?i)^(can|may|allow|check|ability)([A-Z]\\w*)?$" +# Stub Rego template. The verb/resource pair is captured as identifiers +# (not string literals), so {{verb}} and {{resource}} substitute in the +# *names* (e.g. "edit", "post") rather than runtime values. Imprecise +# by design — generated policies are meant for manual review. Sister +# rules without literal-arg captures (e.g. java-spring-preauthorize) +# follow the same TODO-stub convention. +[rule.rego_template] +template = """ +default allow := false + +# TODO: chained DSL — captured verb/resource are identifiers, not +# runtime values. Replace input.action/input.resource with the actual +# inputs your policy receives. +allow if { + input.action == "{{verb}}" + input.resource == "{{resource}}" +} +""" + # -- Positive: Ghost-style permission DSL -- [[rule.tests]] diff --git a/rules/typescript/membership-check-call.toml b/rules/typescript/membership-check-call.toml new file mode 100644 index 0000000..8124234 --- /dev/null +++ b/rules/typescript/membership-check-call.toml @@ -0,0 +1,95 @@ +[rule] +id = "ts-membership-check-call" +languages = ["typescript", "javascript"] +category = "rbac" +confidence = "high" +description = "Membership or role helper call (e.g. belongsTo(\"team-42\"), isTeamAdmin(\"team-42\"), userIsOwner(\"org-1\"))" +# Sister rule to ts-permission-check-call. Surfaced by the Cal.com +# corpus shakedown: methods like `belongsTo`, `isTeamAdmin`, +# `isOrgOwner`, `hasMembership`, and the `userIs` family are +# first-class authz checks, but the literal argument is typically a +# *resource identifier* (e.g. "team-42", "org-1") rather than a +# permission name. Mixing them into ts-permission-check-call would +# misshape the generated rego (`input.action == "team-42"` is +# nonsense), so they live here with a membership-shaped template. +# +# Per the project learning: keep role-checking and permission-checking +# rule logic intentionally separated. +query = """ +(call_expression + function: (member_expression + property: (property_identifier) @method) + arguments: (arguments + (string) @resource_id) +) @match +""" + +[rule.predicates.method] +match = "^(belongsTo|isTeamAdmin|isOrgOwner|hasMembership|userIs[A-Z][A-Za-z]*)$" + +# Stub template. {{resource_id}} substitutes the captured resource/team +# identifier. The membership shape is encoded as a set lookup against +# input.user.memberships — adjust to match your policy's actual claim +# shape (e.g. nested objects with role+resource pairs). +[rule.rego_template] +template = """ +default allow := false + +# TODO: membership check — replace with your policy's actual membership +# shape. Captured resource_id is a team/org/resource identifier, not a +# permission name. +allow if { + "{{resource_id}}" in input.user.memberships +} +""" + +[[rule.tests]] +input = """ +if (membership.belongsTo("team-42")) { + showTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (acl.isTeamAdmin("team-42")) { + manageTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (session.userIsOwner("org-1")) { + manageOrg(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (user.hasMembership("workspace-7")) { + enter(); +} +""" +expect_match = true + +# Negative: covered by ts-permission-check-call (kept separate per +# the role-vs-permission separation rule). +[[rule.tests]] +input = """ +if (user.can("delete")) { + remove(); +} +""" +expect_match = false + +# Negative: not a membership helper. +[[rule.tests]] +input = """ +if (cache.has("foo")) { + use(); +} +""" +expect_match = false diff --git a/rules/typescript/permission-check-call.toml b/rules/typescript/permission-check-call.toml index 70f3bf3..774b543 100644 --- a/rules/typescript/permission-check-call.toml +++ b/rules/typescript/permission-check-call.toml @@ -4,14 +4,17 @@ languages = ["typescript", "javascript"] category = "abac" confidence = "high" description = "Permission or capability check function call" -# Method name regex covers two families: -# 1. Allow-style ABAC verbs: can, hasPermission, checkPermission, isAllowed, allows, -# hasAccess, checkAccess, hasClaim. Deny-style (cannot, denies) are accepted too -# for completeness, though the rego template below is allow-shaped. -# 2. Membership/role helpers: belongsTo, isTeamAdmin, isOrgOwner, hasMembership, -# and the userIs family (e.g. userIsAdmin, userIsOwner). Surfaced by the -# Cal.com corpus shakedown — these are first-class authz checks but were -# previously unmatched. +# Allow-style ABAC verbs: can, hasPermission, checkPermission, isAllowed, +# allows, hasAccess, checkAccess, hasClaim. Deny-style (cannot, denies) +# are accepted for completeness, though the rego template below is +# allow-shaped. +# +# Role/membership helpers (belongsTo, isTeamAdmin, isOrgOwner, +# hasMembership, userIs) intentionally live in +# ts-membership-check-call. Their literal arg is typically a *resource +# identifier* (e.g. "team-42") rather than a permission name, so they +# need a different rego template shape. Keeping the rules separated +# preserves the project's role-vs-permission separation. query = """ (call_expression function: (member_expression @@ -22,7 +25,7 @@ query = """ """ [rule.predicates.method] -match = "^(can|cannot|hasPermission|checkPermission|isAllowed|allows|denies|hasAccess|checkAccess|hasClaim|belongsTo|isTeamAdmin|isOrgOwner|hasMembership|userIs[A-Z][A-Za-z]*)$" +match = "^(can|cannot|hasPermission|checkPermission|isAllowed|allows|denies|hasAccess|checkAccess|hasClaim)$" [rule.rego_template] template = """ @@ -40,28 +43,3 @@ if (user.can("delete", resource)) { } """ expect_match = true - -# Cal.com-style membership/role helpers when called with a string literal. -[[rule.tests]] -input = """ -if (membership.belongsTo("team-42")) { - showTeam(); -} -""" -expect_match = true - -[[rule.tests]] -input = """ -if (acl.isTeamAdmin("team-42")) { - manageTeam(); -} -""" -expect_match = true - -[[rule.tests]] -input = """ -if (session.userIsOwner("org-1")) { - manageOrg(); -} -""" -expect_match = true diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index ead2d74..17e5fab 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -48,6 +48,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "chained-permission-call", include_str!("../../rules/typescript/chained-permission-call.toml"), ), + ( + "membership-check-call", + include_str!("../../rules/typescript/membership-check-call.toml"), + ), ( "session-auth-check", include_str!("../../rules/typescript/session-auth-check.toml"),